Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
62 changes: 62 additions & 0 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -2468,6 +2473,17 @@ class GhosttyApp {
return found && enabled
}

func scrollbarVisibility() -> ScrollbarVisibility {
guard let config else { return .system }
var value: UnsafePointer<Int8>?
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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -9769,6 +9818,7 @@ final class GhosttySurfaceScrollView: NSView {
}

func refreshHostBackgroundAfterGhosttyConfigReload() {
synchronizeScrollbarAppearance()
surfaceView.applySurfaceBackground()
surfaceView.applyWindowBackgroundIfActive()
}
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
35 changes: 10 additions & 25 deletions cmuxTests/TerminalAndGhosttyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
)
}

Expand Down
Loading