From 5d11e612e4c95bae085ca9b1a5f7d27a24d3e55a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 22 Jan 2026 03:47:30 -0800 Subject: [PATCH] Add Ghostty-style scrollbars and focus handling --- Sources/GhosttyTerminalView.swift | 264 +++++++++++++++++++++++++++--- Sources/TerminalView.swift | 208 ++++++++++++++++------- 2 files changed, 392 insertions(+), 80 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 9c2098c4..32c4af71 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -47,8 +47,7 @@ class GhosttyApp { } } runtimeConfig.action_cb = { app, target, action in - // Handle actions - return false + return GhosttyApp.shared.handleAction(target: target, action: action) } runtimeConfig.read_clipboard_cb = { userdata, location, state in // Read clipboard @@ -80,6 +79,38 @@ class GhosttyApp { guard let app = app else { return } ghostty_app_tick(app) } + + private func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { + guard target.tag == GHOSTTY_TARGET_SURFACE else { return false } + guard let userdata = ghostty_surface_userdata(target.target.surface) else { return false } + let surfaceView = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + + switch action.tag { + case GHOSTTY_ACTION_SCROLLBAR: + let scrollbar = GhosttyScrollbar(c: action.action.scrollbar) + surfaceView.scrollbar = scrollbar + NotificationCenter.default.post( + name: .ghosttyDidUpdateScrollbar, + object: surfaceView, + userInfo: [GhosttyNotificationKey.scrollbar: scrollbar] + ) + return true + case GHOSTTY_ACTION_CELL_SIZE: + let cellSize = CGSize( + width: CGFloat(action.action.cell_size.width), + height: CGFloat(action.action.cell_size.height) + ) + surfaceView.cellSize = cellSize + NotificationCenter.default.post( + name: .ghosttyDidUpdateCellSize, + object: surfaceView, + userInfo: [GhosttyNotificationKey.cellSize: cellSize] + ) + return true + default: + return false + } + } } // MARK: - Terminal Surface (owns the ghostty_surface_t lifecycle) @@ -124,6 +155,7 @@ class TerminalSurface { var surfaceConfig = ghostty_surface_config_new() surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS surfaceConfig.platform.macos.nsview = Unmanaged.passUnretained(view).toOpaque() + surfaceConfig.userdata = Unmanaged.passUnretained(view).toOpaque() surfaceConfig.scale_factor = scale surfaceConfig.context = GHOSTTY_SURFACE_CONTEXT_TAB @@ -149,7 +181,6 @@ class TerminalSurface { let scale = view.window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 updateMetalLayer(for: view) - // Update the nsview pointer in the surface ghostty_surface_set_content_scale(surface, scale, scale) ghostty_surface_set_size( @@ -222,6 +253,8 @@ class TerminalSurface { class GhosttyNSView: NSView { var terminalSurface: TerminalSurface? private var surfaceAttached = false + var scrollbar: GhosttyScrollbar? + var cellSize: CGSize = .zero override func makeBackingLayer() -> CALayer { let metalLayer = CAMetalLayer() @@ -294,6 +327,13 @@ class GhosttyNSView: NSView { terminalSurface?.surface } + func performBindingAction(_ action: String) -> Bool { + guard let surface = surface else { return false } + return action.withCString { cString in + ghostty_surface_binding_action(surface, cString, UInt(strlen(cString))) + } + } + // MARK: - Input Handling override var acceptsFirstResponder: Bool { true } @@ -514,6 +554,198 @@ class GhosttyNSView: NSView { } } +struct GhosttyScrollbar { + let total: UInt64 + let offset: UInt64 + let len: UInt64 + + init(c: ghostty_action_scrollbar_s) { + total = c.total + offset = c.offset + len = c.len + } +} + +enum GhosttyNotificationKey { + static let scrollbar = "ghostty.scrollbar" + static let cellSize = "ghostty.cellSize" +} + +extension Notification.Name { + static let ghosttyDidUpdateScrollbar = Notification.Name("ghosttyDidUpdateScrollbar") + static let ghosttyDidUpdateCellSize = Notification.Name("ghosttyDidUpdateCellSize") +} + +// MARK: - Scroll View Wrapper (Ghostty-style scrollbar) + +final class GhosttySurfaceScrollView: NSView { + private let scrollView: NSScrollView + private let documentView: NSView + private let surfaceView: GhosttyNSView + private var observers: [NSObjectProtocol] = [] + private var isLiveScrolling = false + private var lastSentRow: Int? + + init(surfaceView: GhosttyNSView) { + self.surfaceView = surfaceView + scrollView = NSScrollView() + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = false + scrollView.usesPredominantAxisScrolling = true + scrollView.scrollerStyle = .overlay + scrollView.drawsBackground = false + scrollView.contentView.clipsToBounds = false + + documentView = NSView(frame: .zero) + scrollView.documentView = documentView + documentView.addSubview(surfaceView) + + super.init(frame: .zero) + + addSubview(scrollView) + + scrollView.contentView.postsBoundsChangedNotifications = true + observers.append(NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] _ in + self?.handleScrollChange() + }) + + 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: .ghosttyDidUpdateScrollbar, + object: surfaceView, + queue: .main + ) { [weak self] notification in + self?.handleScrollbarUpdate(notification) + }) + + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidUpdateCellSize, + object: surfaceView, + queue: .main + ) { [weak self] _ in + self?.synchronizeScrollView() + }) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) not implemented") + } + + deinit { + observers.forEach { NotificationCenter.default.removeObserver($0) } + } + + override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } + + override func layout() { + super.layout() + scrollView.frame = bounds + surfaceView.frame.size = scrollView.bounds.size + documentView.frame.size.width = scrollView.bounds.width + synchronizeScrollView() + synchronizeSurfaceView() + } + + override func scrollWheel(with event: NSEvent) { + // Route scroll wheel events to the surface so the terminal core + // can decide whether to scroll scrollback or send mouse events. + if window?.firstResponder !== surfaceView { + window?.makeFirstResponder(surfaceView) + } + surfaceView.scrollWheel(with: event) + } + + func attachSurface(_ terminalSurface: TerminalSurface) { + surfaceView.attachSurface(terminalSurface) + } + + func setActive(_ active: Bool) { + if active { + DispatchQueue.main.async { + self.window?.makeFirstResponder(self.surfaceView) + } + } else { + surfaceView.terminalSurface?.setFocus(false) + } + } + + private func synchronizeSurfaceView() { + let visibleRect = scrollView.contentView.documentVisibleRect + surfaceView.frame.origin = visibleRect.origin + } + + private func synchronizeScrollView() { + documentView.frame.size.height = documentHeight() + + if !isLiveScrolling { + let cellHeight = surfaceView.cellSize.height + if cellHeight > 0, let scrollbar = surfaceView.scrollbar { + let offsetY = + CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight + scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY)) + lastSentRow = Int(scrollbar.offset) + } + } + + scrollView.reflectScrolledClipView(scrollView.contentView) + } + + private func handleScrollChange() { + synchronizeSurfaceView() + guard isLiveScrolling else { return } + let cellHeight = surfaceView.cellSize.height + guard cellHeight > 0 else { return } + + let visibleRect = scrollView.contentView.documentVisibleRect + let documentHeight = documentView.frame.height + let scrollOffset = documentHeight - visibleRect.origin.y - visibleRect.height + let row = Int(scrollOffset / cellHeight) + + guard row != lastSentRow else { return } + lastSentRow = row + _ = surfaceView.performBindingAction("scroll_to_row:\(row)") + } + + private func handleScrollbarUpdate(_ notification: Notification) { + guard let scrollbar = notification.userInfo?[GhosttyNotificationKey.scrollbar] as? GhosttyScrollbar else { + return + } + surfaceView.scrollbar = scrollbar + synchronizeScrollView() + } + + private func documentHeight() -> CGFloat { + let contentHeight = scrollView.contentSize.height + let cellHeight = surfaceView.cellSize.height + if cellHeight > 0, let scrollbar = surfaceView.scrollbar { + let documentGridHeight = CGFloat(scrollbar.total) * cellHeight + let padding = contentHeight - (CGFloat(scrollbar.len) * cellHeight) + return documentGridHeight + padding + } + return contentHeight + } +} + // MARK: - NSTextInputClient extension GhosttyNSView: NSTextInputClient { @@ -609,30 +841,16 @@ struct GhosttyTerminalView: NSViewRepresentable { let terminalSurface: TerminalSurface var isActive: Bool = true - func makeNSView(context: Context) -> GhosttyNSView { - let view = GhosttyNSView(frame: .zero) + func makeNSView(context: Context) -> GhosttySurfaceScrollView { + let surfaceView = GhosttyNSView(frame: .zero) + let view = GhosttySurfaceScrollView(surfaceView: surfaceView) view.attachSurface(terminalSurface) - // Focus after view is in window - if isActive { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - view.window?.makeFirstResponder(view) - } - } + view.setActive(isActive) return view } - func updateNSView(_ nsView: GhosttyNSView, context: Context) { - // Ensure the surface is attached + func updateNSView(_ nsView: GhosttySurfaceScrollView, context: Context) { nsView.attachSurface(terminalSurface) - - if isActive { - // Focus on tab switch and notify surface - DispatchQueue.main.async { - nsView.window?.makeFirstResponder(nsView) - } - } else { - // Unfocus when tab becomes inactive - terminalSurface.setFocus(false) - } + nsView.setActive(isActive) } } diff --git a/Sources/TerminalView.swift b/Sources/TerminalView.swift index 58d896a3..c40cc7d5 100644 --- a/Sources/TerminalView.swift +++ b/Sources/TerminalView.swift @@ -35,16 +35,54 @@ struct TerminalContainerView: View { } } -// Custom wrapper to handle first responder and layout +final class ScrollReportingTerminalView: LocalProcessTerminalView { + var onScroll: (() -> Void)? + + override func scrolled(source: TerminalView, position: Double) { + super.scrolled(source: source, position: position) + onScroll?() + } + + override func scrollWheel(with event: NSEvent) { + if let scrollView = enclosingScrollView { + scrollView.scrollWheel(with: event) + return + } + super.scrollWheel(with: event) + } +} + +// Custom wrapper to handle first responder and native scrollbars class FocusableTerminalView: NSView { - var terminalView: LocalProcessTerminalView? - private var scroller: NSScroller? - private var fadeTimer: Timer? - private var scrollMonitor: Any? - private var lastScrollerValue: Double = 0 + private let scrollView = NSScrollView() + private let documentView = NSView() + private let debugScrollerOverlay = NSView() + var terminalView: ScrollReportingTerminalView? { + didSet { + configureTerminalView() + } + } + private var observers: [NSObjectProtocol] = [] + private var isLiveScrolling = false + private var isProgrammaticScroll = false + private var lastSentPosition: Double? override var acceptsFirstResponder: Bool { true } + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupScrollView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupScrollView() + } + + deinit { + observers.forEach { NotificationCenter.default.removeObserver($0) } + } + override func becomeFirstResponder() -> Bool { if let tv = terminalView { DispatchQueue.main.async { @@ -61,73 +99,130 @@ class FocusableTerminalView: NSView { override func layout() { super.layout() + scrollView.frame = bounds + let scrollerWidth = NSScroller.scrollerWidth(for: .regular, scrollerStyle: scrollView.scrollerStyle) + debugScrollerOverlay.frame = NSRect(x: bounds.maxX - scrollerWidth, y: 0, width: scrollerWidth, height: bounds.height) if let tv = terminalView, bounds.size.width > 0, bounds.size.height > 0 { - tv.setFrameSize(bounds.size) - setupScrollerTracking(in: tv) + tv.setFrameSize(scrollView.contentSize) + documentView.frame.size.width = scrollView.bounds.width + synchronizeScrollView() + synchronizeTerminalView() } } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if window != nil, let tv = terminalView, bounds.size.width > 0 { - tv.setFrameSize(bounds.size) - setupScrollerTracking(in: tv) - setupScrollMonitor() + tv.setFrameSize(scrollView.contentSize) + documentView.frame.size.width = scrollView.bounds.width + synchronizeScrollView() + synchronizeTerminalView() } } - override func viewWillMove(toWindow newWindow: NSWindow?) { - super.viewWillMove(toWindow: newWindow) - if newWindow == nil, let monitor = scrollMonitor { - NSEvent.removeMonitor(monitor) - scrollMonitor = nil + private func setupScrollView() { + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = false + scrollView.usesPredominantAxisScrolling = true + scrollView.scrollerStyle = .overlay + scrollView.drawsBackground = false + scrollView.contentView.clipsToBounds = false + scrollView.documentView = documentView + addSubview(scrollView) + + debugScrollerOverlay.wantsLayer = true + debugScrollerOverlay.layer?.backgroundColor = NSColor.systemRed.withAlphaComponent(0.25).cgColor + addSubview(debugScrollerOverlay) + + scrollView.contentView.postsBoundsChangedNotifications = true + observers.append(NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] _ in + self?.handleScrollChange() + }) + 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 + }) + } + + private func configureTerminalView() { + guard let tv = terminalView else { return } + tv.onScroll = { [weak self] in + self?.synchronizeScrollView() + } + documentView.addSubview(tv) + hideInternalScroller(in: tv) + synchronizeScrollView() + synchronizeTerminalView() + } + + private func documentHeight() -> CGFloat { + let contentHeight = scrollView.contentSize.height + guard let tv = terminalView, tv.canScroll else { return contentHeight } + let thumb = max(tv.scrollThumbsize, 0.01) + return max(contentHeight / thumb, contentHeight) + } + + private func synchronizeScrollView() { + documentView.frame.size.height = documentHeight() + guard let tv = terminalView else { return } + + if !isLiveScrolling { + let contentHeight = scrollView.contentSize.height + let maxOffset = max(documentView.frame.height - contentHeight, 0) + let offsetY = (1 - tv.scrollPosition) * maxOffset + isProgrammaticScroll = true + scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY)) + scrollView.reflectScrolledClipView(scrollView.contentView) + isProgrammaticScroll = false + } else { + scrollView.reflectScrolledClipView(scrollView.contentView) } } - private func setupScrollerTracking(in view: NSView) { - if scroller == nil { - for subview in view.subviews { - if let s = subview as? NSScroller { - scroller = s - s.alphaValue = 0 // Start hidden - lastScrollerValue = s.doubleValue - break - } - } - } + private func synchronizeTerminalView() { + guard let tv = terminalView else { return } + let visibleRect = scrollView.contentView.documentVisibleRect + tv.frame.origin = visibleRect.origin + tv.frame.size = scrollView.contentSize } - private func setupScrollMonitor() { - guard scrollMonitor == nil else { return } - - // Monitor scroll wheel events - scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in - if let self = self, - let window = self.window, - event.window == window { - self.showScrollerTemporarily() - } - return event + private func handleScrollChange() { + synchronizeTerminalView() + guard !isProgrammaticScroll, let tv = terminalView else { return } + let contentHeight = scrollView.contentSize.height + let maxOffset = max(documentView.frame.height - contentHeight, 0) + guard maxOffset > 0 else { return } + let offsetY = scrollView.contentView.documentVisibleRect.origin.y + let position = 1 - Double(offsetY / maxOffset) + if let last = lastSentPosition, abs(last - position) < 0.0001 { + return } + lastSentPosition = position + tv.scroll(toPosition: position) } - func showScrollerTemporarily() { - guard let scroller = scroller else { return } - - // Show scroller - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.15 - scroller.animator().alphaValue = 1 - } - - // Cancel existing timer - fadeTimer?.invalidate() - - // Fade out after 1.5 seconds of no scrolling - fadeTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.3 - self?.scroller?.animator().alphaValue = 0 + private func hideInternalScroller(in view: NSView) { + for subview in view.subviews { + if let scroller = subview as? NSScroller { + scroller.isHidden = true + scroller.alphaValue = 0 + } else if !subview.subviews.isEmpty { + hideInternalScroller(in: subview) } } } @@ -141,7 +236,7 @@ struct SwiftTermView: NSViewRepresentable { let containerView = FocusableTerminalView() containerView.wantsLayer = true - let terminalView = LocalProcessTerminalView(frame: CGRect(x: 0, y: 0, width: 800, height: 600)) + let terminalView = ScrollReportingTerminalView(frame: CGRect(x: 0, y: 0, width: 800, height: 600)) // Use autoresizingMask instead of Auto Layout for SwiftTerm compatibility terminalView.autoresizingMask = [.width, .height] @@ -172,7 +267,6 @@ struct SwiftTermView: NSViewRepresentable { context.coordinator.terminalView = terminalView context.coordinator.containerView = containerView - containerView.addSubview(terminalView) containerView.terminalView = terminalView // Get shell path