diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 5873bf3c15..9c71a947f9 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -130,6 +130,7 @@ E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */; }; 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */; }; 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; }; + 90E6AF1B61106A3758E5599A /* TerminalScrollerInsetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D805685A2E4FD15CB84E054E /* TerminalScrollerInsetTests.swift */; }; 6B524A0BA34FD46A771335AB /* WorkspaceUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */; }; 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */; }; 1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */; }; @@ -337,6 +338,7 @@ 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserConfigTests.swift; sourceTree = ""; }; 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelTests.swift; sourceTree = ""; }; 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = ""; }; + D805685A2E4FD15CB84E054E /* TerminalScrollerInsetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollerInsetTests.swift; sourceTree = ""; }; 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceUnitTests.swift; sourceTree = ""; }; BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAndDragTests.swift; sourceTree = ""; }; 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutAndCommandPaletteTests.swift; sourceTree = ""; }; @@ -624,6 +626,7 @@ 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */, 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */, + D805685A2E4FD15CB84E054E /* TerminalScrollerInsetTests.swift */, 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */, BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */, 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */, @@ -933,6 +936,7 @@ E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */, 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */, 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */, + 90E6AF1B61106A3758E5599A /* TerminalScrollerInsetTests.swift in Sources */, 6B524A0BA34FD46A771335AB /* WorkspaceUnitTests.swift in Sources */, 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */, 1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */, diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 255e7c98a8..d51deab7f5 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9468,6 +9468,38 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.terminalSurface?.forceRefresh(reason: reason) } + /// Width to reserve on the right edge of the terminal surface so that a + /// persistently-visible vertical scroller does not render on top of + /// terminal content. Returns 0 when the scroller is either absent or + /// transient (overlay style that fades out), in which case briefly + /// overlapping content is acceptable and we keep the full terminal width. + /// + /// `preferredScrollerStyle == .legacy` is the signal for "persistently + /// visible": macOS reports legacy when the user's Show Scroll Bars + /// preference is Always, or Automatic with a mouse attached. cmux forces + /// the NSScrollView to overlay style to avoid a reserved legacy gutter, + /// which means AppKit will not subtract the scroller width for us, so we + /// do it here. + static func verticalScrollerInsetWidth( + hasVerticalScroller: Bool, + preferredScrollerStyle: NSScroller.Style, + scrollerWidth: CGFloat + ) -> CGFloat { + guard hasVerticalScroller else { return 0 } + guard preferredScrollerStyle == .legacy else { return 0 } + return max(0, scrollerWidth) + } + + private func verticalScrollerInsetWidth() -> CGFloat { + let style = NSScroller.preferredScrollerStyle + let width = NSScroller.scrollerWidth(for: .regular, scrollerStyle: style) + return Self.verticalScrollerInsetWidth( + hasVerticalScroller: scrollView.hasVerticalScroller, + preferredScrollerStyle: style, + scrollerWidth: width + ) + } + @discardableResult private func synchronizeGeometryAndContent() -> Bool { CATransaction.begin() @@ -9478,7 +9510,11 @@ final class GhosttySurfaceScrollView: NSView { let previousSurfaceSize = surfaceView.frame.size _ = setFrameIfNeeded(backgroundView, to: bounds) _ = setFrameIfNeeded(scrollView, to: bounds) - let targetSize = scrollView.bounds.size + let scrollerInset = verticalScrollerInsetWidth() + let targetSize = CGSize( + width: max(0, scrollView.bounds.width - scrollerInset), + height: scrollView.bounds.height + ) #if DEBUG logLayoutDuringActiveDrag(targetSize: targetSize) #endif @@ -11463,6 +11499,9 @@ final class GhosttySurfaceScrollView: NSView { /// Match upstream Ghostty behavior: use content area width (excluding non-content /// regions such as scrollbar space) when telling libghostty the terminal size. + /// `surfaceView.frame` is pre-shrunk by `verticalScrollerInsetWidth()` inside + /// `synchronizeGeometryAndContent` so that a persistent vertical scroller does + /// not render over the rightmost terminal column. @discardableResult private func synchronizeCoreSurface() -> Bool { let width = max(0, surfaceView.frame.width) @@ -11637,12 +11676,11 @@ final class GhosttySurfaceScrollView: NSView { return } - synchronizeScrollbarAppearance() - - // Retile just the scroll view so contentSize reflects the current - // scroller preference without perturbing hosted terminal geometry. - scrollView.tile() - _ = synchronizeCoreSurface() + // Re-run the full geometry pass: the scroller inset we reserve on the + // right edge of the terminal depends on preferredScrollerStyle, so the + // surface frame needs to resize when legacy/overlay toggles (e.g. the + // user plugs in a mouse or flips Show Scroll Bars). + _ = synchronizeGeometryAndContent() } private func handleTerminalScrollBarPreferenceChange() { diff --git a/cmuxTests/TerminalScrollerInsetTests.swift b/cmuxTests/TerminalScrollerInsetTests.swift new file mode 100644 index 0000000000..82368c520f --- /dev/null +++ b/cmuxTests/TerminalScrollerInsetTests.swift @@ -0,0 +1,59 @@ +import XCTest +import AppKit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Regression tests for https://github.com/manaflow-ai/cmux/issues/1082 and +/// https://github.com/manaflow-ai/cmux/issues/2997. A persistent vertical +/// scroller rendered on top of the rightmost terminal column because the +/// surface width passed to libghostty did not account for the scroller gutter. +/// The terminal reserves the inset only when macOS is going to render a +/// persistent (legacy) scroller; transient overlay scrollers that fade out +/// keep the full terminal width. +final class TerminalScrollerInsetTests: XCTestCase { + func testReservesNothingWhenScrollerIsAbsent() { + let inset = GhosttySurfaceScrollView.verticalScrollerInsetWidth( + hasVerticalScroller: false, + preferredScrollerStyle: .legacy, + scrollerWidth: 15 + ) + XCTAssertEqual(inset, 0) + } + + func testReservesNothingForTransientOverlayScrollers() { + // Trackpad / transient-overlay case: scroller fades out when idle, so + // briefly overlapping content during scroll is acceptable in exchange + // for keeping the full terminal width. + let inset = GhosttySurfaceScrollView.verticalScrollerInsetWidth( + hasVerticalScroller: true, + preferredScrollerStyle: .overlay, + scrollerWidth: 15 + ) + XCTAssertEqual(inset, 0) + } + + func testReservesScrollerWidthForPersistentLegacyScrollers() { + // macOS reports legacy when "Show scroll bars" is Always, or Automatic + // with a mouse attached — in both cases the scroller is permanently + // visible, so we must shrink the terminal surface by its width. + let inset = GhosttySurfaceScrollView.verticalScrollerInsetWidth( + hasVerticalScroller: true, + preferredScrollerStyle: .legacy, + scrollerWidth: 15 + ) + XCTAssertEqual(inset, 15) + } + + func testClampsNegativeScrollerWidthToZero() { + let inset = GhosttySurfaceScrollView.verticalScrollerInsetWidth( + hasVerticalScroller: true, + preferredScrollerStyle: .legacy, + scrollerWidth: -4 + ) + XCTAssertEqual(inset, 0) + } +}