diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 3fe3705dc836..eba3d12d8239 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5809,8 +5809,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func refreshTerminalSurfacesAfterGhosttyConfigReload(source: String) { var refreshedCount = 0 forEachTerminalPanel { terminalPanel in - terminalPanel.hostedView.reconcileGeometryNow() terminalPanel.hostedView.refreshHostBackgroundAfterGhosttyConfigReload() + terminalPanel.hostedView.reconcileGeometryNow() terminalPanel.surface.forceRefresh(reason: "appDelegate.refreshAfterGhosttyConfigReload") refreshedCount += 1 } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 517be0a06ba7..0ae3be69b68d 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1181,6 +1181,11 @@ private final class GhosttySurfaceCallbackContext { // MARK: - Ghostty App Singleton class GhosttyApp { + enum ScrollbarVisibility: String { + case system + case never + } + static let shared = GhosttyApp() private static let releaseBundleIdentifier = "com.cmuxterm.app" private static let backgroundLogTimestampFormatter: ISO8601DateFormatter = { @@ -2468,6 +2473,17 @@ class GhosttyApp { return found && enabled } + func scrollbarVisibility() -> ScrollbarVisibility { + guard let config else { return .system } + var value: UnsafePointer? + let key = "scrollbar" + guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))), + let value else { + return .system + } + return ScrollbarVisibility(rawValue: String(cString: value)) ?? .system + } + func appleScriptAutomationEnabled() -> Bool { guard let config else { return false } var enabled = false @@ -8532,6 +8548,7 @@ final class GhosttySurfaceScrollView: NSView { private var searchOverlayMutationGeneration: UInt64 = 0 private var observers: [NSObjectProtocol] = [] private var windowObservers: [NSObjectProtocol] = [] + private var scrollbarTrackingArea: NSTrackingArea? private var isLiveScrolling = false private var lastSentRow: Int? /// Tracks whether the user has scrolled away from the bottom to review scrollback. @@ -8777,6 +8794,7 @@ final class GhosttySurfaceScrollView: NSView { backgroundView.layer?.isOpaque = initialTerminalBackground.alphaComponent >= 1.0 addSubview(backgroundView) addSubview(scrollView) + synchronizeScrollbarAppearance() inactiveOverlayView.wantsLayer = true inactiveOverlayView.layer?.backgroundColor = NSColor.clear.cgColor inactiveOverlayView.isHidden = true @@ -9072,6 +9090,37 @@ final class GhosttySurfaceScrollView: NSView { // Avoid stealing focus on scroll; focus is managed explicitly by the surface view. override var acceptsFirstResponder: Bool { false } + override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + guard scrollView.hasVerticalScroller, + NSScroller.preferredScrollerStyle == .legacy else { return } + scrollView.flashScrollers() + } + + override func updateTrackingAreas() { + if let scrollbarTrackingArea { + removeTrackingArea(scrollbarTrackingArea) + self.scrollbarTrackingArea = nil + } + + super.updateTrackingAreas() + + guard scrollView.hasVerticalScroller, + let scroller = scrollView.verticalScroller else { return } + + let trackingArea = NSTrackingArea( + rect: convert(scroller.bounds, from: scroller), + options: [ + .mouseMoved, + .activeInKeyWindow, + ], + owner: self, + userInfo: nil + ) + addTrackingArea(trackingArea) + scrollbarTrackingArea = trackingArea + } + override func layout() { super.layout() synchronizeGeometryAndContent() @@ -9769,6 +9818,7 @@ final class GhosttySurfaceScrollView: NSView { } func refreshHostBackgroundAfterGhosttyConfigReload() { + synchronizeScrollbarAppearance() surfaceView.applySurfaceBackground() surfaceView.applyWindowBackgroundIfActive() } @@ -11225,6 +11275,16 @@ final class GhosttySurfaceScrollView: NSView { synchronizeScrollView() } + private func synchronizeScrollbarAppearance() { + scrollView.hasVerticalScroller = GhosttyApp.shared.scrollbarVisibility() != .never + // Mirror upstream Ghostty: keep overlay scrollers even when the + // system preference is legacy so terminal content never sits beneath a + // permanently reserved scrollbar gutter. + scrollView.autohidesScrollers = false + scrollView.scrollerStyle = .overlay + updateTrackingAreas() + } + private func handlePreferredScrollerStyleChange() { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in @@ -11233,6 +11293,8 @@ final class GhosttySurfaceScrollView: NSView { return } + synchronizeScrollbarAppearance() + // Retile just the scroll view so contentSize reflects the current // scrollbar mode without perturbing viewport origin or hosted view // geometry; the broader reconcile path caused visible content glitches. diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 0f6d80eb91ac..bc3836b7ff60 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -2209,7 +2209,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase { XCTAssertTrue(state.isHidden) } - func testPreferredScrollerStyleChangeRecalculatesTerminalSurfaceWidth() { + func testPreferredScrollerStyleChangeRestoresOverlayScrollbarWidth() { let surface = TerminalSurface( tabId: UUID(), context: GHOSTTY_SURFACE_CONTEXT_SPLIT, @@ -2289,32 +2289,17 @@ final class GhosttySurfaceOverlayTests: XCTestCase { NotificationCenter.default.post(name: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil) RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - XCTAssertEqual(scrollView.scrollerStyle, .legacy) - assertPendingSurfaceWidth( - legacyContentWidth, - "Preferred scroller style changes should recalculate the terminal grid width immediately" - ) - - scrollView.scrollerStyle = .overlay - scrollView.layoutSubtreeIfNeeded() - let overlayContentWidth = scrollView.contentSize.width - XCTAssertGreaterThan( - overlayContentWidth, - legacyContentWidth, - "Overlay scrollbars should restore the full terminal content width" - ) - assertPendingSurfaceWidth( - legacyContentWidth, - "Changing the scroll view style alone should leave the terminal grid stale until the scroller-style observer runs" - ) - - NotificationCenter.default.post(name: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - + let restoredContentWidth = scrollView.contentSize.width XCTAssertEqual(scrollView.scrollerStyle, .overlay) + XCTAssertEqual( + restoredContentWidth, + initialContentWidth, + accuracy: 0.5, + "Preferred scroller style changes should restore Ghostty's overlay scrollbar behavior so terminal content is not occluded by a persistent gutter" + ) assertPendingSurfaceWidth( - overlayContentWidth, - "Preferred scroller style changes should also restore the wider terminal grid when overlay scrollbars return" + restoredContentWidth, + "Preferred scroller style changes should restore the wider terminal grid when overlay scrollbars return" ) }