Skip to content
Open
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
4 changes: 4 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -337,6 +338,7 @@
970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserConfigTests.swift; sourceTree = "<group>"; };
58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelTests.swift; sourceTree = "<group>"; };
02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = "<group>"; };
D805685A2E4FD15CB84E054E /* TerminalScrollerInsetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollerInsetTests.swift; sourceTree = "<group>"; };
71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceUnitTests.swift; sourceTree = "<group>"; };
BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAndDragTests.swift; sourceTree = "<group>"; };
6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutAndCommandPaletteTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
52 changes: 45 additions & 7 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Comment on lines +9493 to +9501
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant scrollerWidth query for overlay style

NSScroller.scrollerWidth(for: .regular, scrollerStyle: style) is called unconditionally, but the result is only used when style == .legacy. When style == .overlay the static helper returns 0 before reading scrollerWidth, so the scrollerWidth call is dead work. Consider querying the width only when needed:

Suggested change
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
)
}
private func verticalScrollerInsetWidth() -> CGFloat {
let style = NSScroller.preferredScrollerStyle
guard style == .legacy else { return 0 }
guard scrollView.hasVerticalScroller else { return 0 }
let width = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy)
return max(0, width)
}

This also removes the indirection through the static helper for the hot-path call and makes the guard order match the static helper's logic explicitly.


@discardableResult
private func synchronizeGeometryAndContent() -> Bool {
CATransaction.begin()
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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() {
Expand Down
59 changes: 59 additions & 0 deletions cmuxTests/TerminalScrollerInsetTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
Comment on lines +51 to +58
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Defensive guard against impossible input

testClampsNegativeScrollerWidthToZero exercises a max(0, ...) guard that Apple's NSScroller.scrollerWidth(for:scrollerStyle:) can never trigger — the API is documented to return a non-negative CGFloat. Per the CLAUDE.md test quality policy, tests should verify observable runtime behavior through executable paths, not defensive implementation detail. Consider replacing this case with a test that observes a meaningful boundary condition (e.g. scrollerWidth == 0 with a legacy scroller mounted, confirming the inset is 0 and the surface retains its full width), which would remain useful if scrollerWidth semantics ever change.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

}