mirror-ghostty/macos/Sources/Ghostty/SurfaceScrollView.swift

391 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 = false
scrollView.usesPredominantAxisScrolling = true
// Always use the overlay style. See mouseMoved for how we make
// it usable without a scroll wheel or gestures.
scrollView.scrollerStyle = .overlay
// hide default background to show blur effect properly
scrollView.drawsBackground = false
// don't let the content view clip its subviews, to enable the
// surface to draw the background behind non-overlay scrollers
// (we currently only use overlay scrollers, but might as well
// configure the views correctly in case we change our mind)
scrollView.contentView.clipsToBounds = false
// 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()
})
observers.append(NotificationCenter.default.addObserver(
forName: NSScroller.preferredScrollerStyleDidChangeNotification,
object: nil,
// Since this observer is used to immediately override the event
// that produced the notification, we let it run synchronously on
// the posting thread.
queue: nil
) { [weak self] _ in
self?.handleScrollerStyleChange()
})
// Listen for frame change events. See the docstring for
// handleFrameChange for why this is necessary.
observers.append(NotificationCenter.default.addObserver(
forName: NSView.frameDidChangeNotification,
object: nil,
// Since this observer is used to immediately override the event
// that produced the notification, we let it run synchronously on
// the posting thread.
queue: nil
) { [weak self] notification in
self?.handleFrameChange(notification)
})
// Listen for derived config changes to update scrollbar settings live
surfaceView.$derivedConfig
.sink { [weak self] _ in
DispatchQueue.main.async { [weak self] in
self?.handleConfigChange()
}
}
.store(in: &cancellables)
surfaceView.$pointerStyle
.receive(on: DispatchQueue.main)
.sink { [weak self] newStyle in
self?.scrollView.documentCursor = newStyle.cursor
}
.store(in: &cancellables)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) not implemented")
}
deinit {
observers.forEach { NotificationCenter.default.removeObserver($0) }
}
// The entire bounds is a safe area, so we override any default
// insets. This is necessary for the content view to match the
// surface view if we have the "hidden" titlebar style.
override var safeAreaInsets: NSEdgeInsets { return NSEdgeInsetsZero }
override func layout() {
super.layout()
// Fill entire bounds with scroll view
scrollView.frame = bounds
surfaceView.frame.size = scrollView.bounds.size
// We only set the width of the documentView here, as the height depends
// on the scrollbar state and is updated in synchronizeScrollView
documentView.frame.size.width = scrollView.bounds.width
// When our scrollview changes make sure our scroller and surface views are synchronized
synchronizeScrollView()
synchronizeSurfaceView()
synchronizeCoreSurface()
}
// MARK: Scrolling
private func synchronizeAppearance() {
let scrollbarConfig = surfaceView.derivedConfig.scrollbar
scrollView.hasVerticalScroller = scrollbarConfig != .never
let hasLightBackground = OSColor(surfaceView.derivedConfig.backgroundColor).isLightColor
// Make sure the scrollers appearance matches the surface's background color.
scrollView.appearance = NSAppearance(named: hasLightBackground ? .aqua : .darkAqua)
updateTrackingAreas()
}
/// 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.origin = visibleRect.origin
}
/// Inform the actual pty of our size change. This doesn't change the actual view
/// frame because we do want to render the whole thing, but it will prevent our
/// rows/cols from going into the non-content area.
private func synchronizeCoreSurface() {
// Only update the pty 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 width = scrollView.contentSize.width
let height = surfaceView.frame.height
if width > 0 && height > 0 {
surfaceView.sizeDidChange(CGSize(width: width, height: height))
}
}
/// Sizes the document view and scrolls the content view according to the scrollbar state
private func synchronizeScrollView() {
// Update the document height to give our scroller the correct proportions
documentView.frame.size.height = documentHeight()
// Only update our actual scroll position if we're not actively scrolling.
if !isLiveScrolling {
// Convert row units to pixels using cell height, ignore zero height.
let cellHeight = surfaceView.cellSize.height
if cellHeight > 0, let scrollbar = surfaceView.scrollbar {
// 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)
}
// MARK: Notifications
/// Handles bounds changes in the scroll view's clip view, keeping the surface view synchronized.
private func handleScrollChange(_ notification: Notification) {
synchronizeSurfaceView()
}
/// Handles scrollbar style changes
private func handleScrollerStyleChange() {
scrollView.scrollerStyle = .overlay
synchronizeCoreSurface()
}
/// Handles config changes
private func handleConfigChange() {
synchronizeAppearance()
synchronizeCoreSurface()
}
/// 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
}
surfaceView.scrollbar = scrollbar
synchronizeScrollView()
}
/// Handles a change in the frame of NSScrollPocket styling overlays
///
/// NSScrollView instances are set up with a subview hierarchy which, as far
/// as I can tell, is intended to add a blur effect to any part of a scroll
/// view that lies under the titlebar, presumably to complement a titlebar
/// using liquid glass transparency. This doesn't work correctly with our
/// hidden titlebar style, which does have a titlebar container, albeit
/// hidden. The styling overlays don't care and size themselves to this
/// container, creating a blurry, transparent field that clips the top of
/// the surface view.
///
/// With other titlebar styles, these views always have zero frame size,
/// presumably because there is no overlap between the scroll view and the
/// titlebar container.
///
/// In native fullscreen, the titlebar detaches from the window and these
/// views seem to work a bit differently, taking non-zero sizes for all
/// styles without creating any problems.
///
/// To handle this in a way that minimizes the difference between how the
/// hidden titlebar and other window styles behave, we do as follows: If we
/// have the hidden titlebar style and we're not fullscreen, we listen to
/// frame changes on NSScrollPocket-related objects in scrollView.subviews,
/// and reset their frame to zero.
///
/// See also https://developer.apple.com/forums/thread/798392.
private func handleFrameChange(_ notification: Notification) {
guard let window = window as? HiddenTitlebarTerminalWindow else { return }
guard !window.styleMask.contains(.fullScreen) else { return }
guard let view = notification.object as? NSView else { return }
guard view.className.contains("NSScrollPocket") else { return }
guard scrollView.subviews.contains(view) else { return }
// These guards to avoid an infinite loop don't actually seem necessary.
// The number of times we reach this point during any given event (e.g.,
// creating a split) is the same either way. We keep them anyway out of
// an abundance of caution.
view.postsFrameChangedNotifications = false
view.frame = NSRect(x: 0, y: 0, width: 0, height: 0)
view.postsFrameChangedNotifications = true
}
// MARK: Calculations
/// Calculate the appropriate document view height given a scrollbar state
private func documentHeight() -> CGFloat {
let contentHeight = scrollView.contentSize.height
let cellHeight = surfaceView.cellSize.height
if cellHeight > 0, let scrollbar = surfaceView.scrollbar {
// The document view must have the same vertical padding around the
// scrollback grid as the content view has around the terminal grid
// otherwise the content view loses alignment with the surface.
let documentGridHeight = CGFloat(scrollbar.total) * cellHeight
let padding = contentHeight - (CGFloat(scrollbar.len) * cellHeight)
return documentGridHeight + padding
}
return contentHeight
}
// MARK: Mouse events
override func mouseMoved(with: NSEvent) {
// When the OS preferred style is .legacy, the user should be able to
// click and drag the scroller without using scroll wheels or gestures,
// so we flash it when the mouse is moved over the scrollbar area.
guard NSScroller.preferredScrollerStyle == .legacy else { return }
scrollView.flashScrollers()
}
override func updateTrackingAreas() {
// To update our tracking area we just recreate it all.
trackingAreas.forEach { removeTrackingArea($0) }
super.updateTrackingAreas()
// Our tracking area is the scroller frame
guard let scroller = scrollView.verticalScroller else { return }
addTrackingArea(NSTrackingArea(
rect: convert(scroller.bounds, from: scroller),
options: [
.mouseMoved,
.activeInKeyWindow,
],
owner: self,
userInfo: nil))
}
}