Skip to content
Closed
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
@@ -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 {

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 Implicit Actor Isolation Surface

This new public Sendable value model is used by nonisolated TerminalSurface methods and main-actor lifecycle code, but it is not declared nonisolated. Under cmux's Swift 6 actor-isolation rule, pure Sendable models should opt out explicitly so later compiler or module isolation changes do not couple this runtime state to the main actor and produce isolation diagnostics in the setter paths.

Suggested change
public struct SurfaceOcclusionState: Equatable, Sendable {
nonisolated public struct SurfaceOcclusionState: Equatable, Sendable {

Rule Used: Flag new or materially worsened Swift 6 actor isol... (source)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declining this one: the CmuxTerminal package compiles in plain Swift 6 language mode with no defaultIsolation(MainActor.self) setting, so a top-level public struct here is already nonisolated. Every sibling public Sendable value type in the package (TerminalSurfaceSpawnPolicy, TerminalSurfaceRuntimeFilesystem, TerminalSurfaceRegistryDiagnosticSnapshot) is declared without an explicit nonisolated keyword, so adding it only to SurfaceOcclusionState would deviate from the package's existing convention rather than follow it.

/// 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ extension TerminalSurface {
pendingSocketInputQueue.removeAll(keepingCapacity: false)
pendingSocketInputBytes = 0
desiredFocusState = false
lastAppliedOcclusionVisible = nil

guard let surfaceToFree else {
callbackContext?.release()
Expand Down Expand Up @@ -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
Expand All @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
32 changes: 21 additions & 11 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Window occlusion seeded after create

Medium Severity

setWindowOcclusionVisible runs after attachToView, which can synchronously createSurface and apply occlusionState while windowVisible still defaults to true. An occluded host window can briefly get ghostty_surface_set_occlusion(true) plus forceRefreshSurface/ghostty_surface_refresh before the window axis is corrected.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 00343ea. Configure here.

if !isAlreadyAttached {
updateSurfaceSize()
}
Expand All @@ -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
Expand Down Expand Up @@ -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))

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.

P1 Assigned Surface Misses Occlusion

When a GhosttyNSView already belongs to a miniaturized or fully covered window and a TerminalSurface is assigned afterward, this window-axis seed has already run. The new surface keeps windowVisible at its default true until a later occlusion notification, so a surface created in an already-hidden window can keep rendering off-screen.

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!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 00343ea: attachSurface(_:) now seeds the window-occlusion axis from the current window (window.occlusionState.contains(.visible)) when the view is already in a window, so a surface attached after viewDidMoveToWindow ran no longer keeps the default windowVisible = true inside a miniaturized/covered window.


if let surface = terminalSurface?.surface,
let displayID = window.screen?.displayID,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
Loading