ghostty-org--ghostty
bd44b5aa08
Test / build-libghostty-vt (x86_64-windows-gnu) (push) Has been cancelled
Test / build-libghostty-vt-macos (aarch64-ios) (push) Has been cancelled
Test / build-libghostty-vt-macos (aarch64-macos) (push) Has been cancelled
Test / build-libghostty-vt-macos (x86_64-macos) (push) Has been cancelled
Test / build-nix (namespace-profile-ghostty-md-arm64) (push) Has been cancelled
Test / build-dist (push) Has been cancelled
Test / GTK x11=true wayland=true (push) Has been cancelled
Test / Build -Dsimd=false (push) Has been cancelled
Test / Build -Dsimd=true (push) Has been cancelled
Test / Build -Dsentry=false (push) Has been cancelled
Test / Build -Dsentry=true (push) Has been cancelled
Test / zig-fmt (push) Has been cancelled
Test / GitHub Actions Pins (push) Has been cancelled
Test / prettier (push) Has been cancelled
Test / swiftlint (push) Has been cancelled
Test / alejandra (push) Has been cancelled
Test / typos (push) Has been cancelled
Nix / Required Checks: Nix (push) Has been cancelled
Nix / check-zig-cache-hash (push) Has been cancelled
Test / skip (push) Has been cancelled
Test / Required Checks: Test (push) Has been cancelled
Test / build-bench (push) Has been cancelled
Test / list-examples (push) Has been cancelled
Test / Example ${{ matrix.dir }} (Windows) (push) Has been cancelled
Test / build-cmake (push) Has been cancelled
Test / test-lib-vt-pkgconfig (push) Has been cancelled
Test / build-flatpak (push) Has been cancelled
Test / build-snap (push) Has been cancelled
Test / build-libghostty-vt (aarch64-linux) (push) Has been cancelled
Test / build-libghostty-vt (aarch64-macos) (push) Has been cancelled
Test / build-libghostty-vt (wasm32-freestanding) (push) Has been cancelled
Test / build-libghostty-vt (x86_64-linux) (push) Has been cancelled
Test / build-libghostty-vt (x86_64-linux-musl) (push) Has been cancelled
Test / build-libghostty-vt (x86_64-macos) (push) Has been cancelled
Test / build-libghostty-vt-android (aarch64-linux-android) (push) Has been cancelled
Test / build-libghostty-vt-android (arm-linux-androideabi) (push) Has been cancelled
Test / build-libghostty-vt-android (x86_64-linux-android) (push) Has been cancelled
Test / build-libghostty-vt-windows (push) Has been cancelled
Test / build-libghostty-windows-gnu (push) Has been cancelled
Test / build-linux (namespace-profile-ghostty-md) (push) Has been cancelled
Test / build-linux (namespace-profile-ghostty-md-arm64) (push) Has been cancelled
Test / build-linux-libghostty (push) Has been cancelled
Test / build-nix (namespace-profile-ghostty-md) (push) Has been cancelled
Test / build-dist-lib-vt (push) Has been cancelled
Test / trigger-snap (push) Has been cancelled
Test / trigger-flatpak (push) Has been cancelled
Test / build-macos (push) Has been cancelled
Test / build-macos-freetype (push) Has been cancelled
Test / test (push) Has been cancelled
Test / test-lib-vt (push) Has been cancelled
Test / GTK x11=false wayland=false (push) Has been cancelled
Test / GTK x11=true wayland=false (push) Has been cancelled
Test / GTK x11=false wayland=true (push) Has been cancelled
Test / test-macos (push) Has been cancelled
Test / test-windows (push) Has been cancelled
Test / Build -Di18n=false (push) Has been cancelled
Test / Build -Di18n=true (push) Has been cancelled
Test / Build test/fuzz-libghostty (push) Has been cancelled
Test / shellcheck (push) Has been cancelled
Test / translations (push) Has been cancelled
Test / blueprint-compiler (push) Has been cancelled
Test / Test pkg/wuffs (push) Has been cancelled
Test / Test build on Debian 13 (push) Has been cancelled
Test / valgrind (push) Has been cancelled
Test / Example ${{ matrix.dir }} (push) Has been cancelled
396 行
17 KiB
Swift
396 行
17 KiB
Swift
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 on macOS 26.0. See the docstring for
|
|
// handleFrameChangeForNSScrollPocket for why this is necessary.
|
|
if #unavailable(macOS 26.1) { if #available(macOS 26.0, *) {
|
|
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?.handleFrameChangeForNSScrollPocket(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 scroller’s 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.
|
|
///
|
|
/// This bug is only present in macOS 26.0.
|
|
@available(macOS, introduced: 26.0, obsoleted: 26.1)
|
|
private func handleFrameChangeForNSScrollPocket(_ 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))
|
|
}
|
|
}
|