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
commit
0e0bbfaa61
|
|
@ -741,6 +741,13 @@ typedef struct {
|
|||
uint64_t duration;
|
||||
} 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
|
||||
typedef enum {
|
||||
GHOSTTY_ACTION_QUIT,
|
||||
|
|
@ -767,6 +774,7 @@ typedef enum {
|
|||
GHOSTTY_ACTION_RESET_WINDOW_SIZE,
|
||||
GHOSTTY_ACTION_INITIAL_SIZE,
|
||||
GHOSTTY_ACTION_CELL_SIZE,
|
||||
GHOSTTY_ACTION_SCROLLBAR,
|
||||
GHOSTTY_ACTION_RENDER,
|
||||
GHOSTTY_ACTION_INSPECTOR,
|
||||
GHOSTTY_ACTION_SHOW_GTK_INSPECTOR,
|
||||
|
|
@ -809,6 +817,7 @@ typedef union {
|
|||
ghostty_action_size_limit_s size_limit;
|
||||
ghostty_action_initial_size_s initial_size;
|
||||
ghostty_action_cell_size_s cell_size;
|
||||
ghostty_action_scrollbar_s scrollbar;
|
||||
ghostty_action_inspector_e inspector;
|
||||
ghostty_action_desktop_notification_s desktop_notification;
|
||||
ghostty_action_set_title_s set_title;
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@
|
|||
Ghostty/Ghostty.Surface.swift,
|
||||
Ghostty/InspectorView.swift,
|
||||
"Ghostty/NSEvent+Extension.swift",
|
||||
Ghostty/SurfaceScrollView.swift,
|
||||
Ghostty/SurfaceView_AppKit.swift,
|
||||
Helpers/AppInfo.swift,
|
||||
Helpers/CodableBridge.swift,
|
||||
|
|
|
|||
|
|
@ -100,6 +100,18 @@ extension Ghostty.Action {
|
|||
let state: State
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -571,6 +571,9 @@ extension Ghostty {
|
|||
case GHOSTTY_ACTION_REDO:
|
||||
return redo(app, target: target)
|
||||
|
||||
case GHOSTTY_ACTION_SCROLLBAR:
|
||||
scrollbar(app, target: target, v: action.action.scrollbar)
|
||||
|
||||
case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS:
|
||||
fallthrough
|
||||
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(
|
||||
_ app: ghostty_app_t,
|
||||
target: ghostty_target_s,
|
||||
|
|
|
|||
|
|
@ -603,6 +603,17 @@ extension Ghostty {
|
|||
let str = String(cString: ptr)
|
||||
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
|
||||
}
|
||||
|
||||
enum Scrollbar: String {
|
||||
case system
|
||||
case never
|
||||
}
|
||||
|
||||
enum ResizeOverlay : String {
|
||||
case always
|
||||
case never
|
||||
|
|
|
|||
|
|
@ -344,6 +344,10 @@ extension Notification.Name {
|
|||
|
||||
/// Toggle maximize of current window
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
/// 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.
|
||||
///
|
||||
/// 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 {
|
||||
/// The view to render for the terminal surface.
|
||||
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.
|
||||
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 {
|
||||
// We need the view as part of the state to be created previously because
|
||||
// the view is sent to the Ghostty API so that it can manipulate it
|
||||
// directly since we draw on a render thread.
|
||||
return view;
|
||||
// On iOS, return the surface view directly
|
||||
return view
|
||||
}
|
||||
|
||||
func updateOSView(_ view: SurfaceView, context: Context) {
|
||||
view.sizeDidChange(size)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The configuration for a surface. For any configuration not set, defaults will be chosen from
|
||||
|
|
|
|||
|
|
@ -1532,6 +1532,7 @@ extension Ghostty {
|
|||
let macosWindowShadow: Bool
|
||||
let windowTitleFontFamily: String?
|
||||
let windowAppearance: NSAppearance?
|
||||
let scrollbar: Ghostty.Config.Scrollbar
|
||||
|
||||
init() {
|
||||
self.backgroundColor = Color(NSColor.windowBackgroundColor)
|
||||
|
|
@ -1539,6 +1540,7 @@ extension Ghostty {
|
|||
self.macosWindowShadow = true
|
||||
self.windowTitleFontFamily = nil
|
||||
self.windowAppearance = nil
|
||||
self.scrollbar = .system
|
||||
}
|
||||
|
||||
init(_ config: Ghostty.Config) {
|
||||
|
|
@ -1547,6 +1549,7 @@ extension Ghostty {
|
|||
self.macosWindowShadow = config.macosWindowShadow
|
||||
self.windowTitleFontFamily = config.windowTitleFontFamily
|
||||
self.windowAppearance = .init(ghosttyConfig: config)
|
||||
self.scrollbar = config.scrollbar
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -983,6 +983,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
|
|||
|
||||
.renderer_health => |health| self.updateRendererHealth(health),
|
||||
|
||||
.scrollbar => |scrollbar| self.updateScrollbar(scrollbar),
|
||||
|
||||
.report_color_scheme => |force| self.reportColorScheme(force),
|
||||
|
||||
.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
|
||||
/// so that the apprt can reload the configuration.
|
||||
fn notifyConfigConditionalState(self: *Surface) void {
|
||||
|
|
@ -4814,12 +4827,27 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
|||
}, .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 => {
|
||||
{
|
||||
self.renderer_state.mutex.lock();
|
||||
defer self.renderer_state.mutex.unlock();
|
||||
const sel = self.io.terminal.screen.selection orelse return false;
|
||||
const tl = sel.topLeft(&self.io.terminal.screen);
|
||||
self.io.terminal.screen.scroll(.{ .pin = tl });
|
||||
}
|
||||
|
||||
try self.queueRender();
|
||||
},
|
||||
|
||||
.scroll_page_up => {
|
||||
|
|
|
|||
|
|
@ -164,6 +164,9 @@ pub const Action = union(Key) {
|
|||
/// The cell size has changed to the given dimensions in pixels.
|
||||
cell_size: CellSize,
|
||||
|
||||
/// The scrollbar is updating.
|
||||
scrollbar: terminal.Scrollbar,
|
||||
|
||||
/// The target should be re-rendered. This usually has a specific
|
||||
/// surface target but if the app is targeted then all active
|
||||
/// surfaces should be redrawn.
|
||||
|
|
@ -324,6 +327,7 @@ pub const Action = union(Key) {
|
|||
reset_window_size,
|
||||
initial_size,
|
||||
cell_size,
|
||||
scrollbar,
|
||||
render,
|
||||
inspector,
|
||||
show_gtk_inspector,
|
||||
|
|
|
|||
|
|
@ -728,6 +728,7 @@ pub const Application = extern struct {
|
|||
.command_finished => return Action.commandFinished(target, value),
|
||||
|
||||
// Unimplemented
|
||||
.scrollbar,
|
||||
.secure_input,
|
||||
.close_all_windows,
|
||||
.float_window,
|
||||
|
|
|
|||
|
|
@ -104,6 +104,9 @@ pub const Message = union(enum) {
|
|||
/// of the command.
|
||||
stop_command: ?u8,
|
||||
|
||||
/// The scrollbar state changed for the surface.
|
||||
scrollbar: terminal.Scrollbar,
|
||||
|
||||
pub const ReportTitleStyle = enum {
|
||||
csi_21_t,
|
||||
|
||||
|
|
|
|||
|
|
@ -1197,6 +1197,24 @@ input: RepeatableReadableIO = .{},
|
|||
/// This can be changed at runtime but will only affect new terminal surfaces.
|
||||
@"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
|
||||
/// 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
|
||||
|
|
@ -8379,6 +8397,12 @@ pub const WindowPadding = struct {
|
|||
}
|
||||
};
|
||||
|
||||
/// See scrollbar
|
||||
pub const Scrollbar = enum {
|
||||
system,
|
||||
never,
|
||||
};
|
||||
|
||||
/// See scroll-to-bottom
|
||||
pub const ScrollToBottom = packed struct {
|
||||
keystroke: bool = true,
|
||||
|
|
|
|||
|
|
@ -347,6 +347,10 @@ pub const Action = union(enum) {
|
|||
/// Scroll to the selected text.
|
||||
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_page_up,
|
||||
|
||||
|
|
@ -1077,6 +1081,7 @@ pub const Action = union(enum) {
|
|||
.scroll_to_top,
|
||||
.scroll_to_bottom,
|
||||
.scroll_to_selection,
|
||||
.scroll_to_row,
|
||||
.scroll_page_up,
|
||||
.scroll_page_down,
|
||||
.scroll_page_fractional,
|
||||
|
|
|
|||
|
|
@ -487,6 +487,7 @@ fn actionCommands(action: Action.Key) []const Command {
|
|||
.esc,
|
||||
.cursor_key,
|
||||
.set_font_size,
|
||||
.scroll_to_row,
|
||||
.scroll_page_fractional,
|
||||
.scroll_page_lines,
|
||||
.adjust_selection,
|
||||
|
|
|
|||
|
|
@ -1314,6 +1314,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
|||
self.draw_mutex.lock();
|
||||
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.
|
||||
// that it needs to do before / after `drawFrame`.
|
||||
self.api.drawFrameStart();
|
||||
|
|
|
|||
|
|
@ -1886,6 +1886,11 @@ pub const Scroll = union(enum) {
|
|||
/// the scrollback history.
|
||||
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
|
||||
/// rows. This is clamped to the "top" and "active" top left.
|
||||
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
|
||||
/// previously allocated pages.
|
||||
pub fn scroll(self: *PageList, behavior: Scroll) void {
|
||||
defer self.assertIntegrity();
|
||||
|
||||
switch (behavior) {
|
||||
.active => self.viewport = .active,
|
||||
.top => self.viewport = .top,
|
||||
|
|
@ -1920,6 +1927,93 @@ pub fn scroll(self: *PageList, behavior: Scroll) void {
|
|||
self.viewport = .pin;
|
||||
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_row => |n| delta_row: {
|
||||
switch (self.viewport) {
|
||||
|
|
@ -2110,6 +2204,21 @@ pub const Scrollbar = struct {
|
|||
.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.
|
||||
pub fn eql(self: Scrollbar, other: Scrollbar) bool {
|
||||
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" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
|
@ -5089,7 +5619,7 @@ test "PageList: jump zero prompts" {
|
|||
try testing.expect(s.viewport == .active);
|
||||
|
||||
try testing.expectEqual(Scrollbar{
|
||||
.total = s.totalRows(),
|
||||
.total = s.total_rows,
|
||||
.offset = s.total_rows - s.rows,
|
||||
.len = s.rows,
|
||||
}, s.scrollbar());
|
||||
|
|
@ -5123,7 +5653,7 @@ test "Screen: jump back one prompt" {
|
|||
} }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?);
|
||||
|
||||
try testing.expectEqual(Scrollbar{
|
||||
.total = s.totalRows(),
|
||||
.total = s.total_rows,
|
||||
.offset = 1,
|
||||
.len = s.rows,
|
||||
}, s.scrollbar());
|
||||
|
|
@ -5137,7 +5667,7 @@ test "Screen: jump back one prompt" {
|
|||
} }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?);
|
||||
|
||||
try testing.expectEqual(Scrollbar{
|
||||
.total = s.totalRows(),
|
||||
.total = s.total_rows,
|
||||
.offset = 1,
|
||||
.len = s.rows,
|
||||
}, s.scrollbar());
|
||||
|
|
@ -5148,7 +5678,7 @@ test "Screen: jump back one prompt" {
|
|||
s.scroll(.{ .delta_prompt = 1 });
|
||||
try testing.expect(s.viewport == .active);
|
||||
try testing.expectEqual(Scrollbar{
|
||||
.total = s.totalRows(),
|
||||
.total = s.total_rows,
|
||||
.offset = s.total_rows - s.rows,
|
||||
.len = s.rows,
|
||||
}, s.scrollbar());
|
||||
|
|
@ -5157,7 +5687,7 @@ test "Screen: jump back one prompt" {
|
|||
s.scroll(.{ .delta_prompt = 1 });
|
||||
try testing.expect(s.viewport == .active);
|
||||
try testing.expectEqual(Scrollbar{
|
||||
.total = s.totalRows(),
|
||||
.total = s.total_rows,
|
||||
.offset = s.total_rows - s.rows,
|
||||
.len = s.rows,
|
||||
}, s.scrollbar());
|
||||
|
|
@ -6027,11 +6557,11 @@ test "PageList erase" {
|
|||
try testing.expectEqual(@as(usize, 6), s.totalPages());
|
||||
|
||||
// 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.
|
||||
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
|
||||
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);
|
||||
|
||||
// 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
|
||||
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.
|
||||
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
|
||||
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
|
||||
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
|
||||
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.
|
||||
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
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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 = .{} });
|
||||
try testing.expectEqual(s.rows, s.totalRows());
|
||||
try testing.expectEqual(s.rows, s.total_rows);
|
||||
|
||||
// The row should be empty
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1155,6 +1155,7 @@ pub const Scroll = union(enum) {
|
|||
active,
|
||||
top,
|
||||
pin: Pin,
|
||||
row: usize,
|
||||
delta_row: isize,
|
||||
delta_prompt: isize,
|
||||
};
|
||||
|
|
@ -1174,6 +1175,7 @@ pub inline fn scroll(self: *Screen, behavior: Scroll) void {
|
|||
.active => self.pages.scroll(.{ .active = {} }),
|
||||
.top => self.pages.scroll(.{ .top = {} }),
|
||||
.pin => |p| self.pages.scroll(.{ .pin = p }),
|
||||
.row => |v| self.pages.scroll(.{ .row = v }),
|
||||
.delta_row => |v| self.pages.scroll(.{ .delta_row = v }),
|
||||
.delta_prompt => |v| self.pages.scroll(.{ .delta_prompt = v }),
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue