diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 82de33b1..7a8cb326 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -843,7 +843,6 @@ struct ContentView: View { @State private var titlebarThemeGeneration: UInt64 = 0 @State private var sidebarDraggedTabId: UUID? @State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) - @State private var titlebarThemeUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) @State private var sidebarResizerCursorReleaseWorkItem: DispatchWorkItem? @State private var sidebarResizerPointerMonitor: Any? @State private var isResizerBandActive = false @@ -1128,7 +1127,16 @@ struct ContentView: View { workspace: tab, isWorkspaceVisible: isVisible, isWorkspaceInputActive: isInputActive, - workspacePortalPriority: portalPriority + workspacePortalPriority: portalPriority, + onThemeRefreshRequest: { reason, eventId, source, payloadHex in + scheduleTitlebarThemeRefreshFromWorkspace( + workspaceId: tab.id, + reason: reason, + backgroundEventId: eventId, + backgroundSource: source, + notificationPayloadHex: payloadHex + ) + } ) .opacity(isVisible ? 1 : 0) .allowsHitTesting(isSelectedWorkspace) @@ -1261,12 +1269,47 @@ struct ContentView: View { } } - private func scheduleTitlebarThemeRefresh() { - titlebarThemeUpdateCoalescer.signal { - titlebarThemeGeneration &+= 1 + private func scheduleTitlebarThemeRefresh( + reason: String, + backgroundEventId: UInt64? = nil, + backgroundSource: String? = nil, + notificationPayloadHex: String? = nil + ) { + let previousGeneration = titlebarThemeGeneration + titlebarThemeGeneration &+= 1 + if GhosttyApp.shared.backgroundLogEnabled { + let eventLabel = backgroundEventId.map(String.init) ?? "nil" + let sourceLabel = backgroundSource ?? "nil" + let payloadLabel = notificationPayloadHex ?? "nil" + GhosttyApp.shared.logBackground( + "titlebar theme refresh scheduled reason=\(reason) event=\(eventLabel) source=\(sourceLabel) payload=\(payloadLabel) previousGeneration=\(previousGeneration) generation=\(titlebarThemeGeneration) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))" + ) } } + private func scheduleTitlebarThemeRefreshFromWorkspace( + workspaceId: UUID, + reason: String, + backgroundEventId: UInt64?, + backgroundSource: String?, + notificationPayloadHex: String? + ) { + guard tabManager.selectedTabId == workspaceId else { + guard GhosttyApp.shared.backgroundLogEnabled else { return } + GhosttyApp.shared.logBackground( + "titlebar theme refresh skipped workspace=\(workspaceId.uuidString) selected=\(tabManager.selectedTabId?.uuidString ?? "nil") reason=\(reason)" + ) + return + } + + scheduleTitlebarThemeRefresh( + reason: reason, + backgroundEventId: backgroundEventId, + backgroundSource: backgroundSource, + notificationPayloadHex: notificationPayloadHex + ) + } + private var focusedDirectory: String? { guard let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { @@ -1405,12 +1448,11 @@ struct ContentView: View { scheduleTitlebarTextRefresh() }) - view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ghosttyConfigDidReload"))) { _ in - scheduleTitlebarThemeRefresh() - }) - - view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ghosttyDefaultBackgroundDidChange"))) { _ in - scheduleTitlebarThemeRefresh() + view = AnyView(view.onChange(of: titlebarThemeGeneration) { oldValue, newValue in + guard GhosttyApp.shared.backgroundLogEnabled else { return } + GhosttyApp.shared.logBackground( + "titlebar theme refresh applied oldGeneration=\(oldValue) generation=\(newValue) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))" + ) }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidBecomeFirstResponderSurface)) { notification in diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 71258f18..1a936ec0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -183,7 +183,9 @@ final class GhosttyDefaultBackgroundNotificationDispatcher { let source = pendingSource pendingUserInfo = nil logEvent?("bg notify flushed id=\(eventId) source=\(source)") + logEvent?("bg notify posting id=\(eventId) source=\(source)") postNotification(userInfo) + logEvent?("bg notify posted id=\(eventId) source=\(source)") } } @@ -278,12 +280,17 @@ class GhosttyApp { return UserDefaults.standard.bool(forKey: "GhosttyTabsDebugBG") }() private let backgroundLogURL = GhosttyApp.resolveBackgroundLogURL() + private let backgroundLogStartUptime = ProcessInfo.processInfo.systemUptime + private let backgroundLogLock = NSLock() + private var backgroundLogSequence: UInt64 = 0 private var appObservers: [NSObjectProtocol] = [] private var backgroundEventCounter: UInt64 = 0 private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped private var defaultBackgroundScopeSource: String = "initialize" private lazy var defaultBackgroundNotificationDispatcher: GhosttyDefaultBackgroundNotificationDispatcher = - GhosttyDefaultBackgroundNotificationDispatcher(logEvent: { [weak self] message in + // Theme chrome should track terminal theme changes in the same frame. + // Keep coalescing semantics, but flush in the next main turn instead of waiting ~1 frame. + GhosttyDefaultBackgroundNotificationDispatcher(delay: 0, logEvent: { [weak self] message in guard let self, self.backgroundLogEnabled else { return } self.logBackground(message) }) @@ -1166,8 +1173,10 @@ class GhosttyApp { source: "action.config_change.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")", scope: .surface ) - DispatchQueue.main.async { - surfaceView.applyWindowBackgroundIfActive() + if backgroundLogEnabled { + logBackground( + "surface config change deferred terminal bg apply tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")" + ) } return true case GHOSTTY_ACTION_RELOAD_CONFIG: @@ -1273,7 +1282,16 @@ class GhosttyApp { func logBackground(_ message: String) { let timestamp = Self.backgroundLogTimestampFormatter.string(from: Date()) - let line = "\(timestamp) cmux bg: \(message)\n" + let uptimeMs = (ProcessInfo.processInfo.systemUptime - backgroundLogStartUptime) * 1000 + let frame60 = Int((CACurrentMediaTime() * 60.0).rounded(.down)) + let frame120 = Int((CACurrentMediaTime() * 120.0).rounded(.down)) + let threadLabel = Thread.isMainThread ? "main" : "background" + backgroundLogLock.lock() + defer { backgroundLogLock.unlock() } + backgroundLogSequence &+= 1 + let sequence = backgroundLogSequence + let line = + "\(timestamp) seq=\(sequence) t+\(String(format: "%.3f", uptimeMs))ms thread=\(threadLabel) frame60=\(frame60) frame120=\(frame120) cmux bg: \(message)\n" if let data = line.data(using: .utf8) { if FileManager.default.fileExists(atPath: backgroundLogURL.path) == false { FileManager.default.createFile(atPath: backgroundLogURL.path, contents: nil) @@ -1965,6 +1983,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { var onTriggerFlash: (() -> Void)? var backgroundColor: NSColor? private var appliedColorScheme: ghostty_color_scheme_e? + private var lastLoggedSurfaceBackgroundSignature: String? + private var lastLoggedWindowBackgroundSignature: String? private var keySequence: [ghostty_input_trigger_s] = [] private var keyTables: [String] = [] #if DEBUG @@ -2025,6 +2045,15 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { CATransaction.commit() } terminalSurface?.hostedView.setBackgroundColor(color) + if GhosttyApp.shared.backgroundLogEnabled { + let signature = "\(color.hexString()):\(String(format: "%.3f", color.alphaComponent))" + if signature != lastLoggedSurfaceBackgroundSignature { + lastLoggedSurfaceBackgroundSignature = signature + GhosttyApp.shared.logBackground( + "surface background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" + ) + } + } } func applyWindowBackgroundIfActive() { @@ -2042,7 +2071,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { window.isOpaque = color.alphaComponent >= 1.0 } if GhosttyApp.shared.backgroundLogEnabled { - GhosttyApp.shared.logBackground("applied window background tab=\(tabId?.uuidString ?? "unknown") color=\(color) opacity=\(String(format: "%.3f", color.alphaComponent))") + let signature = "\(cmuxShouldUseTransparentBackgroundWindow() ? "transparent" : color.hexString()):\(String(format: "%.3f", color.alphaComponent))" + if signature != lastLoggedWindowBackgroundSignature { + lastLoggedWindowBackgroundSignature = signature + GhosttyApp.shared.logBackground( + "window background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") transparent=\(cmuxShouldUseTransparentBackgroundWindow()) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" + ) + } } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index cb343d56..a0838d0d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -347,11 +347,11 @@ final class Workspace: Identifiable, ObservableObject { ) } - func applyGhosttyChrome(from config: GhosttyConfig) { - applyGhosttyChrome(backgroundColor: config.backgroundColor) + func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") { + applyGhosttyChrome(backgroundColor: config.backgroundColor, reason: reason) } - func applyGhosttyChrome(backgroundColor: NSColor) { + func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") { let currentChromeColors = bonsplitController.configuration.appearance.chromeColors let nextChromeColors = Self.resolvedChromeColors(from: backgroundColor) let isNoOp = currentChromeColors.backgroundHex == nextChromeColors.backgroundHex && @@ -361,7 +361,7 @@ final class Workspace: Identifiable, ObservableObject { let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil" let nextBackgroundHex = nextChromeColors.backgroundHex ?? "nil" GhosttyApp.shared.logBackground( - "theme apply workspace=\(id.uuidString) currentBg=\(currentBackgroundHex) nextBg=\(nextBackgroundHex) currentBorder=\(currentChromeColors.borderHex ?? "nil") nextBorder=\(nextChromeColors.borderHex ?? "nil") noop=\(isNoOp)" + "theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextBackgroundHex) currentBorder=\(currentChromeColors.borderHex ?? "nil") nextBorder=\(nextChromeColors.borderHex ?? "nil") noop=\(isNoOp)" ) } @@ -369,6 +369,11 @@ final class Workspace: Identifiable, ObservableObject { return } bonsplitController.configuration.appearance.chromeColors = nextChromeColors + if GhosttyApp.shared.backgroundLogEnabled { + GhosttyApp.shared.logBackground( + "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") resultingBorder=\(bonsplitController.configuration.appearance.chromeColors.borderHex ?? "nil")" + ) + } } init(title: String = "Terminal", workingDirectory: String? = nil, portOrdinal: Int = 0) { diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 3c33f1ab..d209b4d2 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -9,6 +9,12 @@ struct WorkspaceContentView: View { let isWorkspaceVisible: Bool let isWorkspaceInputActive: Bool let workspacePortalPriority: Int + let onThemeRefreshRequest: (( + _ reason: String, + _ backgroundEventId: UInt64?, + _ backgroundSource: String?, + _ notificationPayloadHex: String? + ) -> Void)? @State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig(reason: "stateInit") @Environment(\.colorScheme) private var colorScheme @EnvironmentObject var notificationStore: TerminalNotificationStore @@ -106,11 +112,17 @@ struct WorkspaceContentView: View { let payloadHex = (notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil" let eventId = (notification.userInfo?[GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value let source = (notification.userInfo?[GhosttyNotificationKey.backgroundSource] as? String) ?? "nil" + logTheme( + "theme notification workspace=\(workspace.id.uuidString) event=\(eventId.map(String.init) ?? "nil") source=\(source) payload=\(payloadHex) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))" + ) // Payload ordering can lag across rapid config/theme updates. // Resolve from GhosttyApp.shared.defaultBackgroundColor to keep tabs aligned // with Ghostty's current runtime theme. refreshGhosttyAppearanceConfig( - reason: "ghosttyDefaultBackgroundDidChange:event=\(eventId.map(String.init) ?? "nil"):source=\(source):payload=\(payloadHex)" + reason: "ghosttyDefaultBackgroundDidChange", + backgroundEventId: eventId, + backgroundSource: source, + notificationPayloadHex: payloadHex ) } } @@ -174,17 +186,61 @@ struct WorkspaceContentView: View { return next } - private func refreshGhosttyAppearanceConfig(reason: String, backgroundOverride: NSColor? = nil) { + private func refreshGhosttyAppearanceConfig( + reason: String, + backgroundOverride: NSColor? = nil, + backgroundEventId: UInt64? = nil, + backgroundSource: String? = nil, + notificationPayloadHex: String? = nil + ) { let previousBackgroundHex = config.backgroundColor.hexString() let next = Self.resolveGhosttyAppearanceConfig( reason: reason, backgroundOverride: backgroundOverride ) + let eventLabel = backgroundEventId.map(String.init) ?? "nil" + let sourceLabel = backgroundSource ?? "nil" + let payloadLabel = notificationPayloadHex ?? "nil" + let backgroundChanged = previousBackgroundHex != next.backgroundColor.hexString() + let shouldRequestTitlebarRefresh = backgroundChanged || reason == "onAppear" logTheme( - "theme refresh workspace=\(workspace.id.uuidString) reason=\(reason) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString()) overrideBg=\(backgroundOverride?.hexString() ?? "nil")" + "theme refresh begin workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) source=\(sourceLabel) payload=\(payloadLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString()) overrideBg=\(backgroundOverride?.hexString() ?? "nil")" + ) + withTransaction(Transaction(animation: nil)) { + config = next + if shouldRequestTitlebarRefresh { + onThemeRefreshRequest?( + reason, + backgroundEventId, + backgroundSource, + notificationPayloadHex + ) + } + } + if !shouldRequestTitlebarRefresh { + logTheme( + "theme refresh titlebar-skip workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString())" + ) + } + logTheme( + "theme refresh config-applied workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) configBg=\(config.backgroundColor.hexString())" + ) + let chromeReason = + "refreshGhosttyAppearanceConfig:reason=\(reason):event=\(eventLabel):source=\(sourceLabel):payload=\(payloadLabel)" + workspace.applyGhosttyChrome(from: next, reason: chromeReason) + if let terminalPanel = workspace.focusedTerminalPanel { + terminalPanel.applyWindowBackgroundIfActive() + logTheme( + "theme refresh terminal-applied workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) panel=\(workspace.focusedPanelId?.uuidString ?? "nil")" + ) + } else { + logTheme( + "theme refresh terminal-skipped workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) focusedPanel=\(workspace.focusedPanelId?.uuidString ?? "nil")" + ) + } + logTheme( + "theme refresh end workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) chromeBg=\(workspace.bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")" ) - config = next - workspace.applyGhosttyChrome(from: next) } private func logTheme(_ message: String) {