diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift index 35c33c3eb9a1..cfc1c7e90e33 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift @@ -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 diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/GhosttyConfigManagedDefaultAppearanceTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/GhosttyConfigManagedDefaultAppearanceTests.swift index a6ee55c58f10..f5d48b7b41cd 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/GhosttyConfigManagedDefaultAppearanceTests.swift +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/GhosttyConfigManagedDefaultAppearanceTests.swift @@ -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 { @@ -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 { diff --git a/Sources/GhosttyScrollView.swift b/Sources/GhosttyScrollView.swift index 82fedece3630..f65f8856fa04 100644 --- a/Sources/GhosttyScrollView.swift +++ b/Sources/GhosttyScrollView.swift @@ -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 diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4375b05a5708..047ca2f023ce 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -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() @@ -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 @@ -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 @@ -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) @@ -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) @@ -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) @@ -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() { diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index ebc4e0f66554..117d936cc67b 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -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 @@ -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)