From 7207ff08d586d96b1d95872fe397986cd84fe977 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 15 Oct 2025 20:45:30 -0700 Subject: [PATCH] macos: SurfaceScrollView --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + macos/Sources/Ghostty/Ghostty.Action.swift | 12 + macos/Sources/Ghostty/Ghostty.App.swift | 30 +++ macos/Sources/Ghostty/Package.swift | 4 + macos/Sources/Ghostty/SurfaceScrollView.swift | 229 ++++++++++++++++++ macos/Sources/Ghostty/SurfaceView.swift | 22 +- 6 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 macos/Sources/Ghostty/SurfaceScrollView.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 388122f62..ae0051c53 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -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, diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 37b1a362d..4921ef8df 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.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. diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index bf34b4a91..91829f95c 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -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, diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 85040d390..e8a3d0976 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -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 diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift new file mode 100644 index 000000000..642d728d9 --- /dev/null +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -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) + } +} diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index aca17c0fc..c650bdf8f 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -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