Merge pull request #345 from manaflow-ai/feat-theme-switch-frame-sync

Keep chrome/theme updates in lockstep on theme switch
This commit is contained in:
Lawrence Chen 2026-02-23 02:46:08 -08:00 committed by GitHub
commit 4fb5da373d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 163 additions and 25 deletions

View file

@ -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

View file

@ -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))"
)
}
}
}

View file

@ -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) {

View file

@ -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) {