From afba0fb4597f185f8ff319d5a5afa17762a42fc8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 00:29:16 -0800 Subject: [PATCH] Coalesce Ghostty background notifications to latest value --- Sources/GhosttyTerminalView.swift | 63 ++++++++++++++++++------- cmuxTests/GhosttyConfigTests.swift | 75 ++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 16 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ed21b275..af2a94a4 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -124,6 +124,48 @@ enum TerminalOpenURLTarget: Equatable { } } +/// Coalesces Ghostty background notifications so consumers only observe +/// the latest runtime background for a burst of updates. +final class GhosttyDefaultBackgroundNotificationDispatcher { + private let coalescer: NotificationBurstCoalescer + private let postNotification: ([AnyHashable: Any]) -> Void + private var pendingUserInfo: [AnyHashable: Any]? + + init( + delay: TimeInterval = 1.0 / 30.0, + postNotification: @escaping ([AnyHashable: Any]) -> Void = { userInfo in + NotificationCenter.default.post( + name: .ghosttyDefaultBackgroundDidChange, + object: nil, + userInfo: userInfo + ) + } + ) { + coalescer = NotificationBurstCoalescer(delay: delay) + self.postNotification = postNotification + } + + func signal(backgroundColor: NSColor, opacity: Double) { + let signalOnMain = { [self] in + pendingUserInfo = [ + GhosttyNotificationKey.backgroundColor: backgroundColor, + GhosttyNotificationKey.backgroundOpacity: opacity + ] + coalescer.signal { [self] in + guard let userInfo = pendingUserInfo else { return } + pendingUserInfo = nil + postNotification(userInfo) + } + } + + if Thread.isMainThread { + signalOnMain() + } else { + DispatchQueue.main.async(execute: signalOnMain) + } + } +} + func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? { let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } @@ -203,6 +245,7 @@ class GhosttyApp { }() private let backgroundLogURL = GhosttyApp.resolveBackgroundLogURL() private var appObservers: [NSObjectProtocol] = [] + private let defaultBackgroundNotificationDispatcher = GhosttyDefaultBackgroundNotificationDispatcher() // Scroll lag tracking private(set) var isScrolling = false @@ -631,22 +674,10 @@ class GhosttyApp { } private func notifyDefaultBackgroundDidChange() { - let userInfo: [AnyHashable: Any] = [ - GhosttyNotificationKey.backgroundColor: defaultBackgroundColor, - GhosttyNotificationKey.backgroundOpacity: defaultBackgroundOpacity - ] - let post = { - NotificationCenter.default.post( - name: .ghosttyDefaultBackgroundDidChange, - object: nil, - userInfo: userInfo - ) - } - if Thread.isMainThread { - post() - } else { - DispatchQueue.main.async(execute: post) - } + defaultBackgroundNotificationDispatcher.signal( + backgroundColor: defaultBackgroundColor, + opacity: defaultBackgroundOpacity + ) } private func performOnMain(_ work: @MainActor () -> T) -> T { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 2e28bf5f..80a5d1f4 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -340,6 +340,81 @@ final class NotificationBurstCoalescerTests: XCTestCase { } } +final class GhosttyDefaultBackgroundNotificationDispatcherTests: XCTestCase { + func testSignalCoalescesBurstToLatestBackground() { + guard let dark = NSColor(hex: "#272822"), + let light = NSColor(hex: "#FDF6E3") else { + XCTFail("Expected valid test colors") + return + } + + let expectation = expectation(description: "coalesced notification") + expectation.expectedFulfillmentCount = 1 + var postedUserInfos: [[AnyHashable: Any]] = [] + + let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(delay: 0.01) { userInfo in + postedUserInfos.append(userInfo) + expectation.fulfill() + } + + DispatchQueue.main.async { + dispatcher.signal(backgroundColor: dark, opacity: 0.95) + dispatcher.signal(backgroundColor: light, opacity: 0.75) + } + + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(postedUserInfos.count, 1) + XCTAssertEqual( + (postedUserInfos[0][GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString(), + "#FDF6E3" + ) + XCTAssertEqual( + postedOpacity(from: postedUserInfos[0][GhosttyNotificationKey.backgroundOpacity]), + 0.75, + accuracy: 0.0001 + ) + } + + func testSignalAcrossSeparateBurstsPostsMultipleNotifications() { + guard let dark = NSColor(hex: "#272822"), + let light = NSColor(hex: "#FDF6E3") else { + XCTFail("Expected valid test colors") + return + } + + let expectation = expectation(description: "two notifications") + expectation.expectedFulfillmentCount = 2 + var postedHexes: [String] = [] + + let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(delay: 0.01) { userInfo in + let hex = (userInfo[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil" + postedHexes.append(hex) + expectation.fulfill() + } + + DispatchQueue.main.async { + dispatcher.signal(backgroundColor: dark, opacity: 1.0) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + dispatcher.signal(backgroundColor: light, opacity: 1.0) + } + } + + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(postedHexes, ["#272822", "#FDF6E3"]) + } + + private func postedOpacity(from value: Any?) -> Double { + if let value = value as? Double { + return value + } + if let value = value as? NSNumber { + return value.doubleValue + } + XCTFail("Expected background opacity payload") + return -1 + } +} + final class RecentlyClosedBrowserStackTests: XCTestCase { func testPopReturnsEntriesInLIFOOrder() { var stack = RecentlyClosedBrowserStack(capacity: 20)