diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/SurfaceOcclusionState.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/SurfaceOcclusionState.swift new file mode 100644 index 000000000000..a1837ceb90ac --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/SurfaceOcclusionState.swift @@ -0,0 +1,27 @@ +/// Two-axis occlusion state for a terminal surface. +/// +/// Ghostty should render only when the surface is both visible in the UI +/// (portal/canvas visibility) and its host window is visible according to +/// `NSWindow.occlusionState`. +public struct SurfaceOcclusionState: Equatable, Sendable { + /// Whether the portal or canvas currently considers the surface visible. + public var uiVisible: Bool + + /// Whether the host window is currently visible to AppKit. + public var windowVisible: Bool + + /// Creates occlusion state with both axes visible by default. + /// + /// - Parameters: + /// - uiVisible: The current portal or canvas visibility. + /// - windowVisible: The current host-window visibility. + public init(uiVisible: Bool = true, windowVisible: Bool = true) { + self.uiVisible = uiVisible + self.windowVisible = windowVisible + } + + /// Whether Ghostty should treat the surface as visible. + public var effectiveVisible: Bool { + uiVisible && windowVisible + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift index d479f70b505e..9f19057afe7c 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift @@ -43,10 +43,28 @@ extension TerminalSurface { } } - /// Applies the occlusion state to the runtime surface. + /// Records the UI visibility axis and applies the effective occlusion. public func setOcclusion(_ visible: Bool) { + occlusionState.uiVisible = visible + applyOcclusionIfNeeded() + } + + /// Records the host-window visibility axis and applies the effective occlusion. + /// + /// Detach and reparent transients intentionally leave this axis unchanged; + /// only an attached `NSWindow` occlusion observation should update it. + public func setWindowOcclusionVisible(_ visible: Bool) { + occlusionState.windowVisible = visible + applyOcclusionIfNeeded() + } + + /// Applies the combined UI/window occlusion state to Ghostty, deduplicated. + private func applyOcclusionIfNeeded() { guard let surface = surface else { return } - ghostty_surface_set_occlusion(surface, visible) + let effectiveVisible = occlusionState.effectiveVisible + guard lastAppliedOcclusionVisible != effectiveVisible else { return } + ghostty_surface_set_occlusion(surface, effectiveVisible) + lastAppliedOcclusionVisible = effectiveVisible } /// Whether this surface currently holds realized GPU renderer resources. diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift index 07df9f811f0c..14156c04ba01 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift @@ -315,6 +315,7 @@ extension TerminalSurface { pendingSocketInputQueue.removeAll(keepingCapacity: false) pendingSocketInputBytes = 0 desiredFocusState = false + lastAppliedOcclusionVisible = nil guard let surfaceToFree else { callbackContext?.release() @@ -597,9 +598,7 @@ extension TerminalSurface { lastYScale = scaleFactors.y } - // Flush remote-tmux output that arrived before the surface existed - // after sizing, so the seed paints into the final grid instead of - // wrapping at Ghostty's default grid. + // Flush remote-tmux output after sizing so the seed paints into the final grid. flushPendingRemoteOutput(to: createdSurface) // Some GhosttyKit builds can drop inherited font_size during post-create @@ -619,10 +618,11 @@ extension TerminalSurface { } } - // Re-apply the desired focus state after creation so the live runtime - // surface converges with any focus changes that happened while the - // surface was being initialized. + // Re-apply states changed while the runtime surface was initializing. ghostty_surface_set_focus(createdSurface, desiredFocusState) + lastAppliedOcclusionVisible = nil + ghostty_surface_set_occlusion(createdSurface, occlusionState.effectiveVisible) + lastAppliedOcclusionVisible = occlusionState.effectiveVisible flushPendingSocketInputIfNeeded() diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index aa85b9784987..61983bda530e 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -245,16 +245,14 @@ public final class TerminalSurface: Identifiable, ObservableObject { /// surface. The Mac sync server reads the tee'd bytes to broadcast /// raw PTY output to paired iPhones (`MobileTerminalByteTee`). var mobileByteTeeLease: (any TerminalByteTeeLease)? - /// The desired focus state for the Ghostty C surface. May be set before the - /// C surface exists (e.g. during layout restoration); `createSurface` - /// reapplies this value once the runtime surface exists, then keeps using it - /// as a dedup guard to avoid redundant `ghostty_surface_set_focus` calls - /// (prevents prompt redraws with P10k). - /// - /// Start unfocused and only opt into focus when the workspace/AppKit focus - /// path explicitly requests it so background panes do not keep a focused - /// state unless the workspace focus path requests it. + /// Desired Ghostty focus, tracked before creation and replayed afterward. + /// Starts false: only the workspace/AppKit focus path opts a pane into + /// focus, so background panes never keep a focused state. var desiredFocusState: Bool = false + /// UI/window visibility axes applied to Ghostty occlusion when a surface exists. + var occlusionState = SurfaceOcclusionState() + /// Last effective occlusion value sent to the Ghostty C surface. + var lastAppliedOcclusionVisible: Bool? /// Bumped after every completed runtime clipboard read. public internal(set) var clipboardReadGeneration = 0 diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/SurfaceOcclusionStateTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/SurfaceOcclusionStateTests.swift new file mode 100644 index 000000000000..cd5a08cdfe09 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/SurfaceOcclusionStateTests.swift @@ -0,0 +1,44 @@ +import Testing +@testable import CmuxTerminal + +@Suite struct SurfaceOcclusionStateTests { + @Test func defaultsAreVisibleOnBothAxes() { + let state = SurfaceOcclusionState() + + #expect(state.uiVisible) + #expect(state.windowVisible) + #expect(state.effectiveVisible) + } + + @Test(arguments: [ + (uiVisible: true, windowVisible: true, effectiveVisible: true), + (uiVisible: true, windowVisible: false, effectiveVisible: false), + (uiVisible: false, windowVisible: true, effectiveVisible: false), + (uiVisible: false, windowVisible: false, effectiveVisible: false) + ]) + func effectiveVisibilityIsTheAndOfBothAxes( + uiVisible: Bool, + windowVisible: Bool, + effectiveVisible: Bool + ) { + let state = SurfaceOcclusionState(uiVisible: uiVisible, windowVisible: windowVisible) + + #expect(state.effectiveVisible == effectiveVisible) + } + + @Test func uiVisibilityMustReturnBeforeWindowVisibilityCanRenderAgain() { + var state = SurfaceOcclusionState() + + state.uiVisible = false + #expect(!state.effectiveVisible) + + state.windowVisible = false + #expect(!state.effectiveVisible) + + state.windowVisible = true + #expect(!state.effectiveVisible) + + state.uiVisible = true + #expect(state.effectiveVisible) + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index b081f35b6dc8..20c2bda02c19 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3630,7 +3630,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif private var eventMonitor: Any? private var trackingArea: NSTrackingArea? - private var windowObserver: NSObjectProtocol? + private var windowObserver: NSObjectProtocol?, occlusionObserver: NSObjectProtocol? private var lastScrollEventTime: CFTimeInterval = 0 private let scrollSpeedAccumulator = TerminalScrollSpeedAccumulator() private var visibleInUI: Bool = true @@ -3869,6 +3869,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { surface.reconcileAttachedWindowIfNeeded(for: self) } surface.setKeyboardCopyModeActive(keyboardCopyModeActive) + // Seed the window-occlusion axis so a surface attached to a view already + // sitting in an occluded window does not keep rendering off-screen. + if let window { + surface.setWindowOcclusionVisible(window.occlusionState.contains(.visible)) + } if !isAlreadyAttached { updateSurfaceSize() } @@ -3878,10 +3883,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() - if let windowObserver { - NotificationCenter.default.removeObserver(windowObserver) - self.windowObserver = nil + for observer in [windowObserver, occlusionObserver].compactMap({ $0 }) { + NotificationCenter.default.removeObserver(observer) } + windowObserver = nil + occlusionObserver = nil // Balance the cursor stack if the view is removed while hover is active if wordPathHoverActive { wordPathHoverActive = false @@ -3916,6 +3922,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) { [weak self] notification in self?.windowDidChangeScreen(notification) } + occlusionObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didChangeOcclusionStateNotification, object: window, queue: .main + ) { [weak self, weak window] _ in + guard let self, let window, self.window === window else { return } + self.terminalSurface?.setWindowOcclusionVisible(window.occlusionState.contains(.visible)) + } + terminalSurface?.setWindowOcclusionVisible(window.occlusionState.contains(.visible)) if let surface = terminalSurface?.surface, let displayID = window.screen?.displayID, @@ -3953,11 +3966,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) } - fileprivate func updateOcclusionState() { - // Intentionally no-op: we don't drive libghostty occlusion from AppKit occlusion state. - // This avoids transient clears during reparenting and keeps rendering logic minimal. - } - override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() if let window { @@ -7505,9 +7513,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } - if let windowObserver { - NotificationCenter.default.removeObserver(windowObserver) + for observer in [windowObserver, occlusionObserver].compactMap({ $0 }) { + NotificationCenter.default.removeObserver(observer) } + windowObserver = nil + occlusionObserver = nil if let trackingArea { removeTrackingArea(trackingArea) }