From 9e6b680d44719ea82879b65ccb70b3acf87386c3 Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 14 Aug 2026 20:05:58 -0300 Subject: [PATCH 1/5] fix: keep shortcut hints compact --- Sources/SidebarShortcutHints.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/SidebarShortcutHints.swift b/Sources/SidebarShortcutHints.swift index 76f76a5e..01ff6792 100644 --- a/Sources/SidebarShortcutHints.swift +++ b/Sources/SidebarShortcutHints.swift @@ -46,6 +46,7 @@ struct ShortcutHintPill: View { tintColor: nil, cornerRadius: 100 ) + .fixedSize(horizontal: true, vertical: true) } else { content .background(ShortcutHintPillBackground(emphasis: emphasis)) From bc9505c3a28aa604a73e538adece84c833c551fe Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 14 Aug 2026 20:06:14 -0300 Subject: [PATCH 2/5] test: cover native traffic light lifecycle --- programaTests/WindowAndDragTests.swift | 133 +++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/programaTests/WindowAndDragTests.swift b/programaTests/WindowAndDragTests.swift index 09e669f6..968df26e 100644 --- a/programaTests/WindowAndDragTests.swift +++ b/programaTests/WindowAndDragTests.swift @@ -250,6 +250,139 @@ final class WindowGlassEffectTests: XCTestCase { } } +@MainActor +final class NativeTrafficLightLifecycleTests: XCTestCase { + private func drainMainQueue() { + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { + DispatchQueue.main.async { + drained.fulfill() + } + } + wait(for: [drained], timeout: 5.0) + } + + func testMainWindowLifecycleLeavesAppKitTrafficLightsUntouched() throws { + _ = NSApplication.shared + let appDelegate = try XCTUnwrap(AppDelegate.shared) + + let defaults = UserDefaults.standard + let savedMode = defaults.object(forKey: WorkspacePresentationModeSettings.modeKey) + defaults.set( + WorkspacePresentationModeSettings.Mode.standard.rawValue, + forKey: WorkspacePresentationModeSettings.modeKey + ) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier("cmux.main.traffic-light-lifecycle-test") + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.contentView = NSView(frame: window.contentLayoutRect) + defer { + if let savedMode { + defaults.set(savedMode, forKey: WorkspacePresentationModeSettings.modeKey) + } else { + defaults.removeObject(forKey: WorkspacePresentationModeSettings.modeKey) + } + drainMainQueue() + window.close() + } + + let buttonTypes: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton] + let nativeButtons = try buttonTypes.map { type in + try XCTUnwrap(window.standardWindowButton(type)) + } + let nativeTargets = nativeButtons.map(\.target) + let nativeActions = nativeButtons.map(\.action) + let nativeEnabledStates = nativeButtons.map(\.isEnabled) + let nativeHiddenStates = nativeButtons.map(\.isHidden) + let initialNativeFrames = nativeButtons.map(\.frame) + var settledNativeFrames = initialNativeFrames + + for button in nativeButtons { + XCTAssertTrue(button.isEnabled, "A standard main-window button should retain its native enabled state") + XCTAssertFalse(button.isHidden, "A standard main-window button should remain visible") + XCTAssertNotNil(button.action, "A standard main-window button should retain its native action") + } + XCTAssertLessThan(initialNativeFrames[0].midX, initialNativeFrames[1].midX) + XCTAssertLessThan(initialNativeFrames[1].midX, initialNativeFrames[2].midX) + + func assertNativeTrafficLightsUnchanged( + after lifecycleStage: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + let currentButtons = buttonTypes.compactMap { window.standardWindowButton($0) } + XCTAssertEqual( + currentButtons.count, + nativeButtons.count, + "\(lifecycleStage) should keep all three native window buttons", + file: file, + line: line + ) + guard currentButtons.count == nativeButtons.count else { return } + + for index in currentButtons.indices { + let current = currentButtons[index] + XCTAssertTrue( + current === nativeButtons[index], + "\(lifecycleStage) should preserve native button identity", + file: file, + line: line + ) + XCTAssertTrue( + current.target === nativeTargets[index], + "\(lifecycleStage) should preserve the native target", + file: file, + line: line + ) + XCTAssertEqual( + current.action, + nativeActions[index], + "\(lifecycleStage) should preserve the native action", + file: file, + line: line + ) + XCTAssertEqual(current.isEnabled, nativeEnabledStates[index], file: file, line: line) + XCTAssertEqual(current.isHidden, nativeHiddenStates[index], file: file, line: line) + XCTAssertEqual(current.frame.origin.x, settledNativeFrames[index].origin.x, accuracy: 0.01, file: file, line: line) + XCTAssertEqual(current.frame.origin.y, settledNativeFrames[index].origin.y, accuracy: 0.01, file: file, line: line) + XCTAssertEqual(current.frame.size.width, settledNativeFrames[index].size.width, accuracy: 0.01, file: file, line: line) + XCTAssertEqual(current.frame.size.height, settledNativeFrames[index].size.height, accuracy: 0.01, file: file, line: line) + } + + XCTAssertLessThan(currentButtons[0].frame.midX, currentButtons[1].frame.midX, file: file, line: line) + XCTAssertLessThan(currentButtons[1].frame.midX, currentButtons[2].frame.midX, file: file, line: line) + } + + appDelegate.attachUpdateAccessory(to: window) + window.contentView?.superview?.layoutSubtreeIfNeeded() + drainMainQueue() + // AppKit owns any geometry change caused by installing the leading accessory. + // This settled native layout is the oracle for subsequent window lifecycle events. + settledNativeFrames = nativeButtons.map(\.frame) + assertNativeTrafficLightsUnchanged(after: "titlebar accessory setup") + + let lifecycleNotifications: [(Notification.Name, String)] = [ + (NSWindow.didEndLiveResizeNotification, "ending live resize"), + (NSWindow.didExitFullScreenNotification, "exiting full screen"), + (NSWindow.didBecomeKeyNotification, "becoming key"), + (NSWindow.didBecomeMainNotification, "becoming main"), + ] + + for (notification, stage) in lifecycleNotifications { + NotificationCenter.default.post(name: notification, object: window) + drainMainQueue() + assertNativeTrafficLightsUnchanged(after: stage) + } + } +} + @MainActor final class AppDelegateWindowContextRoutingTests: XCTestCase { // Every test in this class constructs a throwaway `AppDelegate()`. `AppDelegate.init()` From 56b9034c778e4600ade56de3457f4c4b1631f42b Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 14 Aug 2026 20:17:26 -0300 Subject: [PATCH 3/5] fix: leave traffic lights under AppKit control --- GhosttyTabs.xcodeproj/project.pbxproj | 4 - Sources/AppDelegate.swift | 11 -- Sources/DebugWindows.swift | 10 -- Sources/ProgramaApp.swift | 3 - Sources/SettingsView.swift | 1 - Sources/VerticalTabsSidebar.swift | 4 +- Sources/WindowDecorationsController.swift | 144 ---------------------- 7 files changed, 2 insertions(+), 175 deletions(-) delete mode 100644 Sources/WindowDecorationsController.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 769aa9f3..e8360afd 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -229,7 +229,6 @@ A500120D /* UpdateLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001223 /* UpdateLogStore.swift */; }; A5001207 /* UpdatePopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001217 /* UpdatePopoverView.swift */; }; A5001208 /* UpdateTitlebarAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001218 /* UpdateTitlebarAccessory.swift */; }; - A5001240 /* WindowDecorationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001241 /* WindowDecorationsController.swift */; }; A5001610 /* SessionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001611 /* SessionPersistence.swift */; }; A5001640 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; A5001650 /* ProgramaConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001651 /* ProgramaConfig.swift */; }; @@ -653,7 +652,6 @@ A5001218 /* UpdateTitlebarAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateTitlebarAccessory.swift; sourceTree = ""; }; A5001222 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = ""; }; A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = ""; }; - A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = ""; }; A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = ""; }; A5001651 /* ProgramaConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaConfig.swift; sourceTree = ""; }; A5001653 /* ProgramaConfigExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaConfigExecutor.swift; sourceTree = ""; }; @@ -1107,7 +1105,6 @@ A5001223 /* UpdateLogStore.swift */, A5001217 /* UpdatePopoverView.swift */, A5001218 /* UpdateTitlebarAccessory.swift */, - A5001241 /* WindowDecorationsController.swift */, A5001222 /* WindowAccessor.swift */, A5001611 /* SessionPersistence.swift */, A5001641 /* RemoteRelayZshBootstrap.swift */, @@ -1675,7 +1672,6 @@ A500120D /* UpdateLogStore.swift in Sources */, A5001207 /* UpdatePopoverView.swift in Sources */, A5001208 /* UpdateTitlebarAccessory.swift in Sources */, - A5001240 /* WindowDecorationsController.swift in Sources */, A500120C /* WindowAccessor.swift in Sources */, A5001610 /* SessionPersistence.swift in Sources */, A5001640 /* RemoteRelayZshBootstrap.swift in Sources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 30ac82bf..6ec2f062 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -857,7 +857,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser private var browserAddressBarBlurObserver: NSObjectProtocol? private let updateController = UpdateController() private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(viewModel: updateViewModel) - private let windowDecorationsController = WindowDecorationsController() private var menuBarExtraController: MenuBarExtraController? private static let serviceErrorNoPath = NSString(string: String(localized: "error.clipboardFolderPath", defaultValue: "Could not load any folder path from the clipboard.")) private static let didInstallWindowKeyEquivalentSwizzle: Void = { @@ -1170,10 +1169,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser updateController.startUpdaterIfNeeded() } titlebarAccessoryController.start() - windowDecorationsController.isMainTerminalWindow = { [weak self] window in - self?.isMainTerminalWindow(window) ?? false - } - windowDecorationsController.start() installMainWindowKeyObserver() refreshGhosttyGotoSplitShortcuts() installGhosttyConfigObserver() @@ -4968,7 +4963,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser // Apply shared window styling. attachUpdateAccessory(to: window) - applyWindowDecorations(to: window) // Keep a strong reference so the window isn't deallocated. let controller = MainWindowController(window: window) @@ -5696,10 +5690,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser titlebarAccessoryController.attach(to: window) } - func applyWindowDecorations(to window: NSWindow) { - windowDecorationsController.apply(to: window) - } - func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) { titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView) } @@ -9291,7 +9281,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser WindowGlassEffect.remove(from: window) } AppDelegate.shared?.attachUpdateAccessory(to: window) - AppDelegate.shared?.applyWindowDecorations(to: window) AppDelegate.shared?.registerMainWindow( window, windowId: windowId, diff --git a/Sources/DebugWindows.swift b/Sources/DebugWindows.swift index 70543f05..a9fcd4bb 100644 --- a/Sources/DebugWindows.swift +++ b/Sources/DebugWindows.swift @@ -28,7 +28,6 @@ final class SettingsAboutTitlebarDebugWindowController: NSWindowController, NSWi window.identifier = NSUserInterfaceItemIdentifier("programa.settingsAboutTitlebarDebug") window.center() window.contentView = NSHostingView(rootView: SettingsAboutTitlebarDebugView()) - AppDelegate.shared?.applyWindowDecorations(to: window) super.init(window: window) window.delegate = self } @@ -259,7 +258,6 @@ final class DebugWindowControlsWindowController: NSWindowController, NSWindowDel window.identifier = NSUserInterfaceItemIdentifier("programa.debugWindowControls") window.center() window.contentView = NSHostingView(rootView: DebugWindowControlsView()) - AppDelegate.shared?.applyWindowDecorations(to: window) super.init(window: window) window.delegate = self } @@ -575,7 +573,6 @@ final class BrowserProfilePopoverDebugWindowController: NSWindowController, NSWi window.identifier = NSUserInterfaceItemIdentifier("programa.browserProfilePopoverDebug") window.center() window.contentView = NSHostingView(rootView: BrowserProfilePopoverDebugView()) - AppDelegate.shared?.applyWindowDecorations(to: window) super.init(window: window) window.delegate = self } @@ -771,7 +768,6 @@ final class SidebarDebugWindowController: NSWindowController, NSWindowDelegate { window.identifier = NSUserInterfaceItemIdentifier("programa.sidebarDebug") window.center() window.contentView = NSHostingView(rootView: SidebarDebugView()) - AppDelegate.shared?.applyWindowDecorations(to: window) super.init(window: window) window.delegate = self } @@ -1100,7 +1096,6 @@ final class MenuBarExtraDebugWindowController: NSWindowController, NSWindowDeleg window.identifier = NSUserInterfaceItemIdentifier("programa.menubarDebug") window.center() window.contentView = NSHostingView(rootView: MenuBarExtraDebugView()) - AppDelegate.shared?.applyWindowDecorations(to: window) super.init(window: window) window.delegate = self } @@ -1273,7 +1268,6 @@ final class BrowserToolbarGlassDebugWindowController: NSWindowController, NSWind window.identifier = NSUserInterfaceItemIdentifier("programa.browserToolbarGlassDebug") window.center() window.contentView = NSHostingView(rootView: BrowserToolbarGlassDebugView()) - AppDelegate.shared?.applyWindowDecorations(to: window) super.init(window: window) window.delegate = self } @@ -1348,7 +1342,6 @@ final class OverlayGlassDebugWindowController: NSWindowController, NSWindowDeleg window.identifier = NSUserInterfaceItemIdentifier("programa.overlayGlassDebug") window.center() window.contentView = NSHostingView(rootView: OverlayGlassDebugView()) - AppDelegate.shared?.applyWindowDecorations(to: window) super.init(window: window) window.delegate = self } @@ -1423,7 +1416,6 @@ final class TabBarGlassDebugWindowController: NSWindowController, NSWindowDelega window.identifier = NSUserInterfaceItemIdentifier("programa.tabBarGlassDebug") window.center() window.contentView = NSHostingView(rootView: TabBarGlassDebugView()) - AppDelegate.shared?.applyWindowDecorations(to: window) super.init(window: window) window.delegate = self } @@ -1501,7 +1493,6 @@ final class SplitButtonLayoutDebugWindowController: NSWindowController, NSWindow window.identifier = NSUserInterfaceItemIdentifier("programa.splitButtonLayoutDebug") window.center() window.contentView = NSHostingView(rootView: SplitButtonLayoutDebugView()) - AppDelegate.shared?.applyWindowDecorations(to: window) super.init(window: window) window.delegate = self } @@ -1571,7 +1562,6 @@ final class BackgroundDebugWindowController: NSWindowController, NSWindowDelegat window.identifier = NSUserInterfaceItemIdentifier("programa.backgroundDebug") window.center() window.contentView = NSHostingView(rootView: BackgroundDebugView()) - AppDelegate.shared?.applyWindowDecorations(to: window) super.init(window: window) window.delegate = self } diff --git a/Sources/ProgramaApp.swift b/Sources/ProgramaApp.swift index 6293c101..c128a20a 100644 --- a/Sources/ProgramaApp.swift +++ b/Sources/ProgramaApp.swift @@ -1410,7 +1410,6 @@ final class SettingsAboutTitlebarStyleStore: ObservableObject { window.contentMinSize = kind.minimumSize window.contentMaxSize = maxSize window.invalidateShadow() - AppDelegate.shared?.applyWindowDecorations(to: window) } private func ensureToolbar(on window: NSWindow, kind: SettingsAboutWindowKind) { @@ -1452,7 +1451,6 @@ private final class AboutWindowController: NSWindowController, NSWindowDelegate window.center() window.contentView = NSHostingView(rootView: AboutPanelView()) SettingsAboutTitlebarStyleStore.shared.applyCurrentOptions(to: window, for: .about) - AppDelegate.shared?.applyWindowDecorations(to: window) super.init(window: window) window.delegate = self } @@ -1552,7 +1550,6 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { window.center() window.contentView = NSHostingView(rootView: SettingsRootView()) SettingsAboutTitlebarStyleStore.shared.applyCurrentOptions(to: window, for: .settings) - AppDelegate.shared?.applyWindowDecorations(to: window) super.init(window: window) window.delegate = self } diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 8da0a002..cc11c339 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -2338,7 +2338,6 @@ struct SettingsRootView: View { guard identifier.hasPrefix("cmux.") else { continue } window.removeTitlebarAccessoryViewController(at: index) } - AppDelegate.shared?.applyWindowDecorations(to: window) } private func applyCurrentSettingsWindowStyle(to window: NSWindow) { diff --git a/Sources/VerticalTabsSidebar.swift b/Sources/VerticalTabsSidebar.swift index c9b2b476..fad40e40 100644 --- a/Sources/VerticalTabsSidebar.swift +++ b/Sources/VerticalTabsSidebar.swift @@ -231,8 +231,8 @@ struct VerticalTabsSidebar: View { HiddenTitlebarSidebarControlsView(notificationStore: notificationStore) } .frame(height: trafficLightPadding) - // Flush sidebar (no panel inset): keep the header content on the - // 25pt traffic-light midline the decorations controller targets. + // Flush sidebar (no panel inset): keep the header aligned with + // AppKit's native titlebar controls. .padding(.top, usesBackdropSidebar ? WindowGlassEffect.sidebarPanelInset : 0) .contentShape(Rectangle()) .background( diff --git a/Sources/WindowDecorationsController.swift b/Sources/WindowDecorationsController.swift deleted file mode 100644 index 15e4ca04..00000000 --- a/Sources/WindowDecorationsController.swift +++ /dev/null @@ -1,144 +0,0 @@ -import AppKit -import Bonsplit - -final class WindowDecorationsController { - /// Injected by AppDelegate: NSWindow.identifier alone can't distinguish the - /// SwiftUI-created main terminal windows. - var isMainTerminalWindow: ((NSWindow) -> Bool)? - private var observers: [NSObjectProtocol] = [] - private var didStart = false - private var trafficLightBaseFrames: [ObjectIdentifier: [NSWindow.ButtonType: NSRect]] = [:] - - func start() { - guard !didStart else { return } - didStart = true - attachToExistingWindows() - installObservers() - } - - func apply(to window: NSWindow) { - let shouldHideButtons = shouldHideTrafficLights(for: window) - hideStandardButtons(on: window, hidden: shouldHideButtons) - applyTrafficLightOffset(on: window, hidden: shouldHideButtons) - } - - private func installObservers() { - let center = NotificationCenter.default - let handler: (Notification) -> Void = { [weak self] notification in - guard let self, let window = notification.object as? NSWindow else { return } - self.apply(to: window) - } - observers.append(center.addObserver(forName: NSWindow.didBecomeKeyNotification, object: nil, queue: .main, using: handler)) - observers.append(center.addObserver(forName: NSWindow.didBecomeMainNotification, object: nil, queue: .main, using: handler)) - // Titlebar layout resets button positions on resize and fullscreen churn. - observers.append(center.addObserver(forName: NSWindow.didEndLiveResizeNotification, object: nil, queue: .main, using: handler)) - observers.append(center.addObserver(forName: NSWindow.didExitFullScreenNotification, object: nil, queue: .main, using: handler)) - // Live resize relayouts the titlebar per frame and snaps the buttons back - // to the stock corner until the drag ends; re-seat them synchronously on - // every resize tick so they hold position through the whole drag. - observers.append(center.addObserver(forName: NSWindow.didResizeNotification, object: nil, queue: .main) { [weak self] notification in - guard let self, let window = notification.object as? NSWindow else { return } - guard window.inLiveResize else { return } - let hidden = self.shouldHideTrafficLights(for: window) - let offset = hidden ? NSPoint.zero : self.trafficLightOffset(for: window) - self.applyTrafficLightOffsetNow(on: window, offset: offset) - }) - } - - private func attachToExistingWindows() { - for window in NSApp.windows { - apply(to: window) - } - } - - private func hideStandardButtons(on window: NSWindow, hidden: Bool) { - window.standardWindowButton(.closeButton)?.isHidden = hidden - window.standardWindowButton(.miniaturizeButton)?.isHidden = hidden - window.standardWindowButton(.zoomButton)?.isHidden = hidden - } - - private func applyTrafficLightOffset(on window: NSWindow, hidden: Bool) { - // Titlebar accessory sizing keeps relayouting the button row for a beat - // after a window becomes key; a single async pass gets reverted. Re-apply - // on a short settle ladder. - for delay in [0.0, 0.4, 1.2] { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self, weak window] in - guard let self, let window else { return } - let offset = hidden ? NSPoint.zero : self.trafficLightOffset(for: window) - self.applyTrafficLightOffsetNow(on: window, offset: offset) - } - } - } - - private func applyTrafficLightOffsetNow(on window: NSWindow, offset: NSPoint) { - let key = ObjectIdentifier(window) - let buttonTypes: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton] - var baseFrames = trafficLightBaseFrames[key] ?? [:] - - for type in buttonTypes { - guard let button = window.standardWindowButton(type) else { continue } - if baseFrames[type] == nil || (baseFrames[type]?.isEmpty ?? true) { - baseFrames[type] = button.frame - } - } - - trafficLightBaseFrames[key] = baseFrames - - for (index, type) in buttonTypes.enumerated() { - guard let button = window.standardWindowButton(type), let base = baseFrames[type] else { continue } - var target = NSPoint(x: base.origin.x + offset.x, y: base.origin.y + offset.y) - if isMainTerminalWindow?(window) == true, - let container = button.superview, - container.bounds.height >= 28 { - // Match Maps' Tahoe-style metrics: 24pt pitch (the hidden- - // titlebar default keeps the cramped legacy 20pt pitch), - // centered on the sidebar header's 25pt midline. Skipped when - // the titlebar container is collapsed (minimal mode) — pushing - // buttons into a degenerate container re-expands it and - // reintroduces a top safe-area inset the mode must not have. - let firstButtonX: CGFloat = 17 - let buttonPitch: CGFloat = 24 - let rowCenterFromTop: CGFloat = 25 - target.x = firstButtonX + CGFloat(index) * buttonPitch - let targetMidY = container.bounds.height - rowCenterFromTop - target.y = targetMidY - base.height / 2 - } - button.setFrameOrigin(target) -#if DEBUG - if type == .closeButton { - dlog( - "decor.lights ident=\(window.identifier?.rawValue.prefix(12) ?? "nil") " + - "base=\(base) target=\(target) offset=\(offset) " + - "containerH=\(button.superview?.bounds.height ?? -1)" - ) - } -#endif - } - } - - private func trafficLightOffset(for window: NSWindow) -> NSPoint { - if window.identifier?.rawValue == "cmux.settings" { - // Nudge controls slightly right/down to align with the custom Settings title row. - return NSPoint(x: 7, y: -4) - } - if isMainTerminalWindow?(window) == true { - // Horizontal seat inside the glass panel; the vertical position is - // computed geometrically in applyTrafficLightOffsetNow. - return NSPoint(x: 6, y: 0) - } - return .zero - } - - private func shouldHideTrafficLights(for window: NSWindow) -> Bool { - if window.isSheet { - return true - } - if window.styleMask.contains(.docModalWindow) { - return true - } - if window.styleMask.contains(.nonactivatingPanel) { - return true - } - return false - } -} From edfef81ef5c5eff1d508f7580dfb903d8473f0d7 Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 14 Aug 2026 20:32:18 -0300 Subject: [PATCH 4/5] test: isolate native traffic light lifecycle --- programaTests/WindowAndDragTests.swift | 151 +++++++++---------------- 1 file changed, 55 insertions(+), 96 deletions(-) diff --git a/programaTests/WindowAndDragTests.swift b/programaTests/WindowAndDragTests.swift index 968df26e..33c8b19c 100644 --- a/programaTests/WindowAndDragTests.swift +++ b/programaTests/WindowAndDragTests.swift @@ -262,124 +262,83 @@ final class NativeTrafficLightLifecycleTests: XCTestCase { wait(for: [drained], timeout: 5.0) } - func testMainWindowLifecycleLeavesAppKitTrafficLightsUntouched() throws { + func testSettingsWindowLifecycleLeavesAppKitTrafficLightsUntouched() throws { _ = NSApplication.shared - let appDelegate = try XCTUnwrap(AppDelegate.shared) - - let defaults = UserDefaults.standard - let savedMode = defaults.object(forKey: WorkspacePresentationModeSettings.modeKey) - defaults.set( - WorkspacePresentationModeSettings.Mode.standard.rawValue, - forKey: WorkspacePresentationModeSettings.modeKey - ) + _ = try XCTUnwrap(AppDelegate.shared) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false ) - window.identifier = NSUserInterfaceItemIdentifier("cmux.main.traffic-light-lifecycle-test") - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - window.contentView = NSView(frame: window.contentLayoutRect) - defer { - if let savedMode { - defaults.set(savedMode, forKey: WorkspacePresentationModeSettings.modeKey) - } else { - defaults.removeObject(forKey: WorkspacePresentationModeSettings.modeKey) - } - drainMainQueue() - window.close() - } + window.identifier = NSUserInterfaceItemIdentifier("cmux.settings") + defer { window.close() } + + window.contentView?.superview?.layoutSubtreeIfNeeded() + drainMainQueue() + window.contentView?.superview?.layoutSubtreeIfNeeded() let buttonTypes: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton] + let buttonNames = ["close", "miniaturize", "zoom"] let nativeButtons = try buttonTypes.map { type in - try XCTUnwrap(window.standardWindowButton(type)) + try XCTUnwrap( + window.standardWindowButton(type), + "The settings window should have every native traffic-light button" + ) } let nativeTargets = nativeButtons.map(\.target) let nativeActions = nativeButtons.map(\.action) let nativeEnabledStates = nativeButtons.map(\.isEnabled) let nativeHiddenStates = nativeButtons.map(\.isHidden) - let initialNativeFrames = nativeButtons.map(\.frame) - var settledNativeFrames = initialNativeFrames + let nativeFrames = nativeButtons.map(\.frame) - for button in nativeButtons { - XCTAssertTrue(button.isEnabled, "A standard main-window button should retain its native enabled state") - XCTAssertFalse(button.isHidden, "A standard main-window button should remain visible") - XCTAssertNotNil(button.action, "A standard main-window button should retain its native action") + for (index, button) in nativeButtons.enumerated() { + XCTAssertTrue(button.isEnabled, "The \(buttonNames[index]) button should be enabled") + XCTAssertFalse(button.isHidden, "The \(buttonNames[index]) button should be visible") + XCTAssertNotNil(button.action, "The \(buttonNames[index]) button should have its native action") } - XCTAssertLessThan(initialNativeFrames[0].midX, initialNativeFrames[1].midX) - XCTAssertLessThan(initialNativeFrames[1].midX, initialNativeFrames[2].midX) + XCTAssertLessThan(nativeFrames[0].minX, nativeFrames[1].minX) + XCTAssertLessThan(nativeFrames[1].minX, nativeFrames[2].minX) - func assertNativeTrafficLightsUnchanged( - after lifecycleStage: String, - file: StaticString = #filePath, - line: UInt = #line - ) { - let currentButtons = buttonTypes.compactMap { window.standardWindowButton($0) } - XCTAssertEqual( - currentButtons.count, - nativeButtons.count, - "\(lifecycleStage) should keep all three native window buttons", - file: file, - line: line - ) - guard currentButtons.count == nativeButtons.count else { return } - - for index in currentButtons.indices { - let current = currentButtons[index] - XCTAssertTrue( - current === nativeButtons[index], - "\(lifecycleStage) should preserve native button identity", - file: file, - line: line - ) - XCTAssertTrue( - current.target === nativeTargets[index], - "\(lifecycleStage) should preserve the native target", - file: file, - line: line - ) - XCTAssertEqual( - current.action, - nativeActions[index], - "\(lifecycleStage) should preserve the native action", - file: file, - line: line - ) - XCTAssertEqual(current.isEnabled, nativeEnabledStates[index], file: file, line: line) - XCTAssertEqual(current.isHidden, nativeHiddenStates[index], file: file, line: line) - XCTAssertEqual(current.frame.origin.x, settledNativeFrames[index].origin.x, accuracy: 0.01, file: file, line: line) - XCTAssertEqual(current.frame.origin.y, settledNativeFrames[index].origin.y, accuracy: 0.01, file: file, line: line) - XCTAssertEqual(current.frame.size.width, settledNativeFrames[index].size.width, accuracy: 0.01, file: file, line: line) - XCTAssertEqual(current.frame.size.height, settledNativeFrames[index].size.height, accuracy: 0.01, file: file, line: line) - } + NotificationCenter.default.post(name: NSWindow.didBecomeMainNotification, object: window) + drainMainQueue() - XCTAssertLessThan(currentButtons[0].frame.midX, currentButtons[1].frame.midX, file: file, line: line) - XCTAssertLessThan(currentButtons[1].frame.midX, currentButtons[2].frame.midX, file: file, line: line) - } + let currentButtons = buttonTypes.compactMap { window.standardWindowButton($0) } + XCTAssertEqual(currentButtons.count, nativeButtons.count, "The settings window should keep all three native buttons") + guard currentButtons.count == nativeButtons.count else { return } - appDelegate.attachUpdateAccessory(to: window) - window.contentView?.superview?.layoutSubtreeIfNeeded() - drainMainQueue() - // AppKit owns any geometry change caused by installing the leading accessory. - // This settled native layout is the oracle for subsequent window lifecycle events. - settledNativeFrames = nativeButtons.map(\.frame) - assertNativeTrafficLightsUnchanged(after: "titlebar accessory setup") - - let lifecycleNotifications: [(Notification.Name, String)] = [ - (NSWindow.didEndLiveResizeNotification, "ending live resize"), - (NSWindow.didExitFullScreenNotification, "exiting full screen"), - (NSWindow.didBecomeKeyNotification, "becoming key"), - (NSWindow.didBecomeMainNotification, "becoming main"), - ] + for index in currentButtons.indices { + let current = currentButtons[index] + let baselineFrame = nativeFrames[index] + let originDelta = NSPoint( + x: current.frame.origin.x - baselineFrame.origin.x, + y: current.frame.origin.y - baselineFrame.origin.y + ) - for (notification, stage) in lifecycleNotifications { - NotificationCenter.default.post(name: notification, object: window) - drainMainQueue() - assertNativeTrafficLightsUnchanged(after: stage) + XCTAssertTrue(current === nativeButtons[index], "The \(buttonNames[index]) button identity should remain native") + XCTAssertTrue(current.target === nativeTargets[index], "The \(buttonNames[index]) button target should remain unchanged") + XCTAssertEqual(current.action, nativeActions[index], "The \(buttonNames[index]) button action should remain unchanged") + XCTAssertEqual(current.isEnabled, nativeEnabledStates[index], "The \(buttonNames[index]) button enabled state should remain unchanged") + XCTAssertEqual(current.isHidden, nativeHiddenStates[index], "The \(buttonNames[index]) button visibility should remain unchanged") + XCTAssertEqual( + current.frame.origin.x, + baselineFrame.origin.x, + accuracy: 0.01, + "The \(buttonNames[index]) button x origin changed by \(originDelta.x)" + ) + XCTAssertEqual( + current.frame.origin.y, + baselineFrame.origin.y, + accuracy: 0.01, + "The \(buttonNames[index]) button y origin changed by \(originDelta.y)" + ) + XCTAssertEqual(current.frame.size.width, baselineFrame.size.width, accuracy: 0.01) + XCTAssertEqual(current.frame.size.height, baselineFrame.size.height, accuracy: 0.01) } + + XCTAssertLessThan(currentButtons[0].frame.minX, currentButtons[1].frame.minX) + XCTAssertLessThan(currentButtons[1].frame.minX, currentButtons[2].frame.minX) } } From 3180b7a246c6e40f25fa68b2d92b9f97ab163847 Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 14 Aug 2026 20:58:56 -0300 Subject: [PATCH 5/5] test: keep native window fixture inert --- programaTests/WindowAndDragTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/programaTests/WindowAndDragTests.swift b/programaTests/WindowAndDragTests.swift index 33c8b19c..fcf3377b 100644 --- a/programaTests/WindowAndDragTests.swift +++ b/programaTests/WindowAndDragTests.swift @@ -273,7 +273,7 @@ final class NativeTrafficLightLifecycleTests: XCTestCase { defer: false ) window.identifier = NSUserInterfaceItemIdentifier("cmux.settings") - defer { window.close() } + defer { window.orderOut(nil) } window.contentView?.superview?.layoutSubtreeIfNeeded() drainMainQueue()