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
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ public struct GhosttyConfig {
/// terminal view/engine code.
public typealias ColorSchemePreference = TerminalColorSchemePreference

/// Native fallback light theme name used for fresh installs before the user
/// has chosen terminal colors.
public static let cmuxDefaultLightThemeName = "Apple System Colors Light"
/// Native fallback dark theme name used for fresh installs before the user
/// has chosen terminal colors.
public static let cmuxDefaultDarkThemeName = "Apple System Colors"
/// Catppuccin's light palette used for fresh installs before the user has
/// chosen terminal colors. This keeps the default terminal in sync with
/// Codex's default TUI theme.
public static let cmuxDefaultLightThemeName = "Catppuccin Latte"
/// Catppuccin's dark palette used for fresh installs before the user has
/// chosen terminal colors. This keeps the default terminal in sync with
/// Codex's default TUI theme.
public static let cmuxDefaultDarkThemeName = "Catppuccin Mocha"

private static let loadCacheLock = NSLock()
// Every read/write of this cache is serialized by `loadCacheLock`; the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@ import Testing
/// Regression coverage for https://github.com/manaflow-ai/cmux/issues/7161
/// and https://github.com/manaflow-ai/cmux/issues/10199.
///
/// cmux's managed default terminal theme ("Apple System Colors") applies only
/// cmux's managed default terminal theme (Catppuccin Latte/Mocha) applies only
/// when enabled and the user has no effective Ghostty settings. Any configured
/// directive preserves Ghostty's own resolved base and user overrides.
@Suite(.serialized) struct GhosttyConfigManagedDefaultAppearanceTests {
@Test("managed defaults match the Codex dark and light palettes")
func managedDefaultsMatchCodexThemePair() {
#expect(GhosttyConfig.cmuxDefaultLightThemeName == "Catppuccin Latte")
#expect(GhosttyConfig.cmuxDefaultDarkThemeName == "Catppuccin Mocha")
}

private func withTempConfigDir(
body: (_ dir: URL) throws -> Void
) throws {
Expand Down Expand Up @@ -171,7 +177,7 @@ import Testing

// MARK: Managed base + user override precedence

/// Writes sentinel "Apple System Colors" theme files into a themes root the
/// Writes sentinel managed theme files into a themes root the
/// managed-default resolution finds via `GHOSTTY_RESOURCES_DIR`, keeping the
/// resolved managed colors deterministic on machines with Ghostty installed.
private func makeManagedThemesRoot(in dir: URL) throws -> URL {
Expand Down
5 changes: 5 additions & 0 deletions Sources/GhosttyScrollView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ final class GhosttyScrollView: NSScrollView {
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)

// NSScrollView is a layer-backed viewport in the terminal hierarchy.
// Keep its view boundary clipped even when AppKit retile/reparent work
// changes the backing layer during a live window resize.
clipsToBounds = true

// Bonsplit lays out the tab strip outside this viewport, so AppKit must not
// infer another terminal-content inset from the window title bar.
automaticallyAdjustsContentInsets = false
Expand Down
59 changes: 42 additions & 17 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4255,6 +4255,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// GhosttyMetalLayer provides render stats and opt-in frame notifications for
// input sequencing that needs to wait for terminal redraws.
wantsLayer = true
// Ghostty installs and can replace the backing layer after this view is
// created. Keep clipping at the view boundary as well as on the layer,
// so a stale drawable cannot paint outside the terminal pane.
clipsToBounds = true
layer?.masksToBounds = true
setupKeyboardCopyModeCursorOverlay()
installEventMonitor()
Expand Down Expand Up @@ -5249,6 +5253,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
if pendingSurfaceSize != size { deferredSurfaceSizeNonMetalRetryCount = 0 }
pendingSurfaceSize = size
// Reassert the view-level boundary on every size publication. AppKit
// may re-materialize the layer while a window is resized, and the
// renderer must remain contained without adding a redraw or queue hop.
clipsToBounds = true
layer?.masksToBounds = true
if let deferralReason = activeSurfaceResizeDeferralReason() {
scheduleDeferredSurfaceSizeRetryIfNeeded()
#if DEBUG
Expand Down Expand Up @@ -10124,12 +10133,22 @@ final class GhosttySurfaceScrollView: NSView {
scrollView.surfaceView = surfaceView

documentView = NSView(frame: .zero)
// The surface is positioned explicitly below. Clearing its inherited
// autoresizing mask prevents AppKit from growing it past the viewport
// between two synchronous resize ticks.
surfaceView.autoresizingMask = []
surfaceView.translatesAutoresizingMaskIntoConstraints = true
scrollView.documentView = documentView
documentView.addSubview(surfaceView)

super.init(frame: .zero)
wantsLayer = true
clipsToBounds = true
layer?.masksToBounds = true
scrollView.clipsToBounds = true
documentView.clipsToBounds = true
surfaceView.clipsToBounds = true
surfaceView.layer?.masksToBounds = true

backgroundView.wantsLayer = true
backgroundView.layer?.backgroundColor = NSColor.clear.cgColor
Expand Down Expand Up @@ -10587,6 +10606,17 @@ final class GhosttySurfaceScrollView: NSView {
forceViewportSync: Bool? = nil,
preservedReviewOriginY: CGFloat? = nil
) -> Bool {
// Keep every AppKit boundary in the rendering chain clipped. These
// assignments are idempotent and avoid any deferred layout or display
// work, which is important while the window resize callback is open.
clipsToBounds = true
layer?.masksToBounds = true
scrollView.clipsToBounds = true
scrollView.contentView.clipsToBounds = true
documentView.clipsToBounds = true
surfaceView.clipsToBounds = true
surfaceView.layer?.masksToBounds = true
surfaceView.autoresizingMask = []
let preservedReviewOriginY = preservedReviewOriginY ?? {
guard scrollbackViewportIntent.preservesViewportDuringPendingSync else { return nil }
return max(scrollView.contentView.bounds.origin.y, 0)
Expand All @@ -10603,17 +10633,13 @@ final class GhosttySurfaceScrollView: NSView {
_ = setFrameIfNeeded(backgroundView, to: bounds)
let contentFrame = sessionContentFrame
_ = setFrameIfNeeded(scrollView, to: contentFrame)
let targetSize = scrollView.bounds.size
if didScrollbarAppearanceChange {
scrollView.tile()
}
let targetSize = synchronizeTerminalContentFrames()
#if DEBUG
logLayoutDuringActiveDrag(targetSize: targetSize)
#endif
let targetSurfaceFrame = CGRect(origin: surfaceView.frame.origin, size: targetSize)
_ = setFrameIfNeeded(surfaceView, to: targetSurfaceFrame)
let targetDocumentFrame = CGRect(
origin: documentView.frame.origin,
size: CGSize(width: scrollView.bounds.width, height: documentView.frame.height)
)
_ = setFrameIfNeeded(documentView, to: targetDocumentFrame)
_ = setFrameIfNeeded(mobileViewportBorderOverlayView, to: contentFrame)
_ = setFrameIfNeeded(inactiveOverlayView, to: bounds)
_ = setFrameIfNeeded(paneDropTargetView, to: bounds)
Expand Down Expand Up @@ -10647,12 +10673,6 @@ final class GhosttySurfaceScrollView: NSView {
_ = setFrameIfNeeded(overlay, to: contentFrame)
}
bringPaneDropTargetToFrontIfNeeded()
// NSScrollView can defer clip-view/content-size updates until its own layout pass,
// which makes interactive width changes arrive a queue turn late on Sequoia.
if didScrollbarAppearanceChange {
scrollView.tile()
}
scrollView.layoutSubtreeIfNeeded()
updateNotificationRingPath()
updateFlashPath(style: lastFlashStyle)
updateFlashAppearance(style: lastFlashStyle)
Expand Down Expand Up @@ -13397,17 +13417,22 @@ final class GhosttySurfaceScrollView: NSView {
}

private func synchronizeTerminalGeometryAfterScrollerStyleChange() {
_ = synchronizeTerminalContentFrames()
synchronizeSurfaceView()
_ = synchronizeCoreSurface()
}

private func synchronizeTerminalContentFrames() -> CGSize {
scrollView.layoutSubtreeIfNeeded()
let targetSize = scrollView.contentView.bounds.size
let targetSurfaceFrame = CGRect(origin: surfaceView.frame.origin, size: targetSize)
_ = setFrameIfNeeded(surfaceView, to: targetSurfaceFrame)
let targetDocumentFrame = CGRect(
origin: documentView.frame.origin,
size: CGSize(width: scrollView.contentView.bounds.width, height: documentView.frame.height)
size: CGSize(width: targetSize.width, height: documentView.frame.height)
)
_ = setFrameIfNeeded(documentView, to: targetDocumentFrame)
synchronizeSurfaceView()
_ = synchronizeCoreSurface()
return targetSize
}

private func handleTerminalScrollBarPreferenceChange() {
Expand Down
9 changes: 9 additions & 0 deletions Sources/TerminalWindowPortal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,10 @@ final class WindowTerminalPortal: NSObject {
self.window = window
super.init()
hostView.wantsLayer = true
// The portal is a sibling of the SwiftUI content tree. Keep a
// view-level boundary while AppKit and layer-backed terminal views
// change frames during a live resize.
hostView.clipsToBounds = true
hostView.layer?.masksToBounds = true
hostView.postsFrameChangedNotifications = true
hostView.postsBoundsChangedNotifications = true
Expand Down Expand Up @@ -1243,6 +1247,11 @@ final class WindowTerminalPortal: NSObject {
@discardableResult
private func ensureInstalled(syncLayout: Bool = true) -> Bool {
guard let window else { return false }
// AppKit can re-materialize a layer-backed host during a resize.
// Reassert the cheap view and layer clips at this installation choke
// point before any child frame is written.
hostView.clipsToBounds = true
hostView.layer?.masksToBounds = true
guard let (container, reference) = installedTargetIfStillValid(for: window) ?? installationTarget(for: window)
else { return false }
let browserHost = preferredBrowserHost(in: container)
Expand Down
Loading