diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 32c4af71..9c2098c4 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -47,7 +47,8 @@ class GhosttyApp { } } runtimeConfig.action_cb = { app, target, action in - return GhosttyApp.shared.handleAction(target: target, action: action) + // Handle actions + return false } runtimeConfig.read_clipboard_cb = { userdata, location, state in // Read clipboard @@ -79,38 +80,6 @@ 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) @@ -155,7 +124,6 @@ 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 @@ -181,6 +149,7 @@ 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( @@ -253,8 +222,6 @@ 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() @@ -327,13 +294,6 @@ 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 } @@ -554,198 +514,6 @@ 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 { @@ -841,16 +609,30 @@ struct GhosttyTerminalView: NSViewRepresentable { let terminalSurface: TerminalSurface var isActive: Bool = true - func makeNSView(context: Context) -> GhosttySurfaceScrollView { - let surfaceView = GhosttyNSView(frame: .zero) - let view = GhosttySurfaceScrollView(surfaceView: surfaceView) + func makeNSView(context: Context) -> GhosttyNSView { + let view = GhosttyNSView(frame: .zero) view.attachSurface(terminalSurface) - view.setActive(isActive) + // Focus after view is in window + if isActive { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + view.window?.makeFirstResponder(view) + } + } return view } - func updateNSView(_ nsView: GhosttySurfaceScrollView, context: Context) { + func updateNSView(_ nsView: GhosttyNSView, context: Context) { + // Ensure the surface is attached nsView.attachSurface(terminalSurface) - nsView.setActive(isActive) + + 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) + } } } diff --git a/Sources/TerminalView.swift b/Sources/TerminalView.swift index c40cc7d5..58d896a3 100644 --- a/Sources/TerminalView.swift +++ b/Sources/TerminalView.swift @@ -35,54 +35,16 @@ struct TerminalContainerView: View { } } -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 +// Custom wrapper to handle first responder and layout class FocusableTerminalView: NSView { - 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? + var terminalView: LocalProcessTerminalView? + private var scroller: NSScroller? + private var fadeTimer: Timer? + private var scrollMonitor: Any? + private var lastScrollerValue: Double = 0 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 { @@ -99,130 +61,73 @@ 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(scrollView.contentSize) - documentView.frame.size.width = scrollView.bounds.width - synchronizeScrollView() - synchronizeTerminalView() + tv.setFrameSize(bounds.size) + setupScrollerTracking(in: tv) } } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if window != nil, let tv = terminalView, bounds.size.width > 0 { - tv.setFrameSize(scrollView.contentSize) - documentView.frame.size.width = scrollView.bounds.width - synchronizeScrollView() - synchronizeTerminalView() + tv.setFrameSize(bounds.size) + setupScrollerTracking(in: tv) + setupScrollMonitor() } } - 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) + override func viewWillMove(toWindow newWindow: NSWindow?) { + super.viewWillMove(toWindow: newWindow) + if newWindow == nil, let monitor = scrollMonitor { + NSEvent.removeMonitor(monitor) + scrollMonitor = nil } } - 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 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 + 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 + } + } } - lastSentPosition = position - tv.scroll(toPosition: position) } - 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) + 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 + } + } + + 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 } } } @@ -236,7 +141,7 @@ struct SwiftTermView: NSViewRepresentable { let containerView = FocusableTerminalView() containerView.wantsLayer = true - let terminalView = ScrollReportingTerminalView(frame: CGRect(x: 0, y: 0, width: 800, height: 600)) + let terminalView = LocalProcessTerminalView(frame: CGRect(x: 0, y: 0, width: 800, height: 600)) // Use autoresizingMask instead of Auto Layout for SwiftTerm compatibility terminalView.autoresizingMask = [.width, .height] @@ -267,6 +172,7 @@ struct SwiftTermView: NSViewRepresentable { context.coordinator.terminalView = terminalView context.coordinator.containerView = containerView + containerView.addSubview(terminalView) containerView.terminalView = terminalView // Get shell path