macos: SurfaceScrollView
parent
2937aff513
commit
7207ff08d5
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,229 @@
|
|||
import SwiftUI
|
||||
|
||||
/// 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 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 = true
|
||||
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)
|
||||
|
||||
// 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()
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue