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/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)) 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 - } -} diff --git a/programaTests/WindowAndDragTests.swift b/programaTests/WindowAndDragTests.swift index 09e669f6..fcf3377b 100644 --- a/programaTests/WindowAndDragTests.swift +++ b/programaTests/WindowAndDragTests.swift @@ -250,6 +250,98 @@ 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 testSettingsWindowLifecycleLeavesAppKitTrafficLightsUntouched() throws { + _ = NSApplication.shared + _ = try XCTUnwrap(AppDelegate.shared) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier("cmux.settings") + defer { window.orderOut(nil) } + + 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), + "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 nativeFrames = nativeButtons.map(\.frame) + + 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(nativeFrames[0].minX, nativeFrames[1].minX) + XCTAssertLessThan(nativeFrames[1].minX, nativeFrames[2].minX) + + NotificationCenter.default.post(name: NSWindow.didBecomeMainNotification, object: window) + drainMainQueue() + + 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 } + + 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 + ) + + 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) + } +} + @MainActor final class AppDelegateWindowContextRoutingTests: XCTestCase { // Every test in this class constructs a throwaway `AppDelegate()`. `AppDelegate.init()`