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:
commit
4fb5da373d
4 changed files with 163 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue