diff --git a/Sources/App/CmuxMainWindow.swift b/Sources/App/CmuxMainWindow.swift index 806401d56af7..04901bbc7eba 100644 --- a/Sources/App/CmuxMainWindow.swift +++ b/Sources/App/CmuxMainWindow.swift @@ -30,27 +30,6 @@ final class MainWindowHostingView: NSHostingView { @MainActor final class CmuxMainWindow: NSWindow { private var isSoftHiddenForVisibilityController = false - private var isClearingNativeTitlebarProxyIcon = false - - // cmux renders the workspace directory icon inside its custom titlebar, whose layout follows - // the sidebar. AppKit's native proxy icon is positioned in the standard titlebar instead. - override var representedURL: URL? { - get { nil } - set { clearNativeTitlebarProxyIcon() } - } - - override var representedFilename: String { - get { "" } - set { clearNativeTitlebarProxyIcon() } - } - - private func clearNativeTitlebarProxyIcon() { - guard !isClearingNativeTitlebarProxyIcon else { return } - isClearingNativeTitlebarProxyIcon = true - defer { isClearingNativeTitlebarProxyIcon = false } - super.representedURL = nil - super.representedFilename = "" - } func setSoftHiddenForVisibilityController(_ isSoftHidden: Bool) { isSoftHiddenForVisibilityController = isSoftHidden diff --git a/Sources/CmuxLifecycleEventPublishing.swift b/Sources/CmuxLifecycleEventPublishing.swift index 5415015636c9..476598485200 100644 --- a/Sources/CmuxLifecycleEventPublishing.swift +++ b/Sources/CmuxLifecycleEventPublishing.swift @@ -218,15 +218,43 @@ extension Workspace { } } +@MainActor +private enum MainWindowKeyRegainRefresh { + static func refresh(window: NSWindow, context: AppDelegate.MainWindowContext) { + // Window focus regain owns the redraw invariant. Cursor tracking and + // focused subviews can update themselves only after this invalidation. + invalidateContentDisplayTree(window: window) + _ = context.keyboardFocusCoordinator.restoreTargetAfterWindowBecameKey() + } + + private static func invalidateContentDisplayTree(window: NSWindow) { + guard let contentView = window.contentView else { return } + invalidateDisplayTree(rootedAt: contentView) + window.invalidateCursorRects(for: contentView) + } + + private static func invalidateDisplayTree(rootedAt view: NSView) { + guard !view.isHidden else { return } + view.needsDisplay = true + view.layer?.setNeedsDisplay() + for subview in view.subviews { + invalidateDisplayTree(rootedAt: subview) + } + } +} + extension AppDelegate { func handleCmuxWindowBecameKey(_ note: Notification) { guard let window = note.object as? NSWindow else { return } MainActor.assumeIsolated { + let context = contextForMainTerminalWindow(window) setActiveMainWindow(window) if let windowId = mainWindowId(from: window) { publishCmuxWindowLifecycle(name: "window.keyed", windowId: windowId, origin: "appkit_key") } - _ = contextForMainTerminalWindow(window)?.keyboardFocusCoordinator.restoreTargetAfterWindowBecameKey() + if let context { + MainWindowKeyRegainRefresh.refresh(window: window, context: context) + } } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9c9dc725a9af..d8202e70898f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2297,7 +2297,7 @@ struct ContentView: View { TitlebarLeadingInsetReader(inset: $titlebarLeadingInset) .allowsHitTesting(false) - HStack(spacing: TitlebarFolderIconMetrics.iconTitleSpacing) { + HStack(spacing: 8) { if isFullScreen && !sidebarState.isVisible { fullscreenControls } @@ -2305,11 +2305,8 @@ struct ContentView: View { // Draggable folder icon + focused command name if let directory = focusedDirectory { DetachedFolderDragIcon(directory: directory) - .frame( - width: TitlebarFolderIconMetrics.iconSize, - height: TitlebarFolderIconMetrics.iconSize - ) - .padding(.leading, TitlebarFolderIconMetrics.iconLeadingPadding) + .frame(width: 16, height: 16) + .padding(.leading, -6) } Text(titlebarText) diff --git a/Sources/DetachedFolderDragIcon.swift b/Sources/DetachedFolderDragIcon.swift index 19dedf907465..e57a18ce3b93 100644 --- a/Sources/DetachedFolderDragIcon.swift +++ b/Sources/DetachedFolderDragIcon.swift @@ -4,277 +4,15 @@ import SwiftUI struct DetachedFolderDragIcon: NSViewRepresentable { let directory: String - func makeNSView(context: Context) -> DetachedFolderDragIconHostView { - DetachedFolderDragIconHostView(directory: directory) + func makeNSView(context: Context) -> DraggableFolderNSView { + DraggableFolderNSView(directory: directory) } - func updateNSView(_ nsView: DetachedFolderDragIconHostView, context: Context) { - nsView.directory = directory - nsView.syncDetachedIcon() - } -} - -@MainActor -final class DetachedFolderDragIconHostView: NSView { - var directory: String - private var childWindow: NSPanel? - private var iconView: DraggableFolderNSView? - private var observers: [NSObjectProtocol] = [] - private var ancestorViewObservers: [NSObjectProtocol] = [] - private var ancestorViewNotificationStates: [AncestorViewNotificationState] = [] - private var observedAncestorViewIds: [ObjectIdentifier] = [] - private var hasScheduledFrameSync = false - private weak var observedParentWindow: NSWindow? - - init(directory: String) { - self.directory = directory - super.init(frame: NSRect(origin: .zero, size: TitlebarFolderIconMetrics.iconFrameSize)) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - MainActor.assumeIsolated { - tearDownDetachedIcon() - } - } - - override var intrinsicContentSize: NSSize { - TitlebarFolderIconMetrics.iconFrameSize - } - - override var mouseDownCanMoveWindow: Bool { false } - - override var frame: NSRect { - didSet { - syncDetachedIconFrame() - } - } - - override func setFrameOrigin(_ newOrigin: NSPoint) { - super.setFrameOrigin(newOrigin) - syncDetachedIconFrame() - } - - override func setFrameSize(_ newSize: NSSize) { - super.setFrameSize(newSize) - syncDetachedIconFrame() - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - installAncestorViewObservers() - syncDetachedIcon() - } - - override func viewDidMoveToSuperview() { - super.viewDidMoveToSuperview() - installAncestorViewObservers() - syncDetachedIcon() - } - - override func layout() { - super.layout() - syncDetachedIconFrame() - } - - func syncDetachedIcon() { - guard let parentWindow = window else { - tearDownDetachedIcon() - return - } - - let child = childWindow ?? makeDetachedIconWindow(parentWindow: parentWindow) - if child.parent !== parentWindow { - child.parent?.removeChildWindow(child) - parentWindow.addChildWindow(child, ordered: .above) - installParentWindowObservers(parentWindow) + func updateNSView(_ nsView: DraggableFolderNSView, context: Context) { + if nsView.directory != directory { + nsView.directory = directory + nsView.updateIcon() } - - if iconView?.directory != directory { - iconView?.directory = directory - iconView?.updateIcon() - } - - child.orderFront(nil) - syncDetachedIconFrame() - } - - #if DEBUG - var detachedIconFrameForTesting: NSRect? { - childWindow?.frame - } - #endif - - private func makeDetachedIconWindow(parentWindow: NSWindow) -> NSPanel { - let iconView = DraggableFolderNSView(directory: directory) - iconView.frame = NSRect(origin: .zero, size: TitlebarFolderIconMetrics.iconFrameSize) - - let panel = NSPanel( - contentRect: iconView.frame, - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, - defer: false - ) - panel.contentView = iconView - panel.backgroundColor = .clear - panel.isOpaque = false - panel.hasShadow = false - panel.hidesOnDeactivate = false - panel.ignoresMouseEvents = false - panel.isMovable = false - panel.isMovableByWindowBackground = false - panel.collectionBehavior = [.fullScreenAuxiliary] - panel.identifier = NSUserInterfaceItemIdentifier("cmux.folderDragIcon") - parentWindow.addChildWindow(panel, ordered: .above) - self.childWindow = panel - self.iconView = iconView - installParentWindowObservers(parentWindow) - return panel - } - - private func installParentWindowObservers(_ parentWindow: NSWindow) { - guard observedParentWindow !== parentWindow || observers.isEmpty else { return } - removeParentWindowObservers() - - // The child panel frame is derived from this host view in the parent - // window. AppKit does not call layout() when only the parent window - // moves, resizes, or changes miniaturized state, so observe those - // window events and recompute the panel's screen-space frame. - let center = NotificationCenter.default - let names: [Notification.Name] = [ - NSWindow.didMoveNotification, - NSWindow.didResizeNotification, - NSWindow.didMiniaturizeNotification, - NSWindow.didDeminiaturizeNotification, - ] - observers = names.map { name in - center.addObserver(forName: name, object: parentWindow, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { - self?.syncDetachedIconFrame() - } - } - } - observedParentWindow = parentWindow - } - - private func syncDetachedIconFrame() { - installAncestorViewObservers() - guard let parentWindow = window, - let childWindow else { return } - let localRect = bounds.isEmpty - ? NSRect(origin: .zero, size: TitlebarFolderIconMetrics.iconFrameSize) - : bounds - let rectInWindow = convert(localRect, to: nil) - let rectOnScreen = parentWindow.convertToScreen(rectInWindow) - if childWindow.frame.origin != rectOnScreen.origin || childWindow.frame.size != rectOnScreen.size { - childWindow.setFrame(rectOnScreen, display: true) - } - } - - private func installAncestorViewObservers() { - var ancestors: [NSView] = [] - var ancestor = superview - while let view = ancestor { - ancestors.append(view) - ancestor = view.superview - } - - let ancestorIds = ancestors.map { ObjectIdentifier($0) } - guard ancestorIds != observedAncestorViewIds else { return } - - removeAncestorViewObservers() - guard !ancestors.isEmpty else { return } - - let center = NotificationCenter.default - for ancestor in ancestors { - // SwiftUI may keep this host's local frame stable while moving an - // enclosing titlebar/content view, such as when the left sidebar - // opens. Observe the ancestor chain so the detached panel follows - // the host's converted window rect, not just its local frame. - ancestorViewNotificationStates.append(AncestorViewNotificationState( - view: ancestor, - postsFrameChangedNotifications: ancestor.postsFrameChangedNotifications, - postsBoundsChangedNotifications: ancestor.postsBoundsChangedNotifications - )) - ancestor.postsFrameChangedNotifications = true - ancestor.postsBoundsChangedNotifications = true - ancestorViewObservers.append(center.addObserver( - forName: NSView.frameDidChangeNotification, - object: ancestor, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.scheduleDetachedIconFrameSync() - } - }) - ancestorViewObservers.append(center.addObserver( - forName: NSView.boundsDidChangeNotification, - object: ancestor, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.scheduleDetachedIconFrameSync() - } - }) - } - observedAncestorViewIds = ancestorIds - } - - private func scheduleDetachedIconFrameSync() { - guard !hasScheduledFrameSync else { return } - hasScheduledFrameSync = true - DispatchQueue.main.async { [weak self] in - MainActor.assumeIsolated { - guard let self else { return } - self.hasScheduledFrameSync = false - self.syncDetachedIconFrame() - } - } - } - - private func removeAncestorViewObservers() { - for observer in ancestorViewObservers { - NotificationCenter.default.removeObserver(observer) - } - for state in ancestorViewNotificationStates { - state.view?.postsFrameChangedNotifications = state.postsFrameChangedNotifications - state.view?.postsBoundsChangedNotifications = state.postsBoundsChangedNotifications - } - ancestorViewObservers.removeAll() - ancestorViewNotificationStates.removeAll() - observedAncestorViewIds.removeAll() - hasScheduledFrameSync = false - } - - private func removeParentWindowObservers() { - for observer in observers { - NotificationCenter.default.removeObserver(observer) - } - observers.removeAll() - observedParentWindow = nil - } - - private func tearDownDetachedIcon() { - // Observer lifetime is tied to the derived child panel: once this host - // leaves its parent window or deinitializes, remove both so no stale - // panel keeps tracking an old parent window. - removeParentWindowObservers() - removeAncestorViewObservers() - if let childWindow { - childWindow.parent?.removeChildWindow(childWindow) - childWindow.orderOut(nil) - } - childWindow = nil - iconView = nil - } - - private struct AncestorViewNotificationState { - weak var view: NSView? - let postsFrameChangedNotifications: Bool - let postsBoundsChangedNotifications: Bool } } @@ -297,6 +35,7 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { init(directory: String) { self.directory = directory super.init(frame: .zero) + identifier = NSUserInterfaceItemIdentifier("cmux.folderDragIcon") setupImageView() } @@ -305,7 +44,7 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { } override var intrinsicContentSize: NSSize { - TitlebarFolderIconMetrics.iconFrameSize + NSSize(width: 16, height: 16) } override var mouseDownCanMoveWindow: Bool { false } @@ -320,8 +59,8 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { imageView.trailingAnchor.constraint(equalTo: trailingAnchor), imageView.topAnchor.constraint(equalTo: topAnchor), imageView.bottomAnchor.constraint(equalTo: bottomAnchor), - imageView.widthAnchor.constraint(equalToConstant: TitlebarFolderIconMetrics.iconSize), - imageView.heightAnchor.constraint(equalToConstant: TitlebarFolderIconMetrics.iconSize), + imageView.widthAnchor.constraint(equalToConstant: 16), + imageView.heightAnchor.constraint(equalToConstant: 16), ]) let dragHint = String(localized: "sidebar.folderIcon.dragHint", defaultValue: "Drag to open in Finder or another app") toolTip = dragHint @@ -335,7 +74,7 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { #endif let icon = NSWorkspace.shared.icon(forFile: directory) - icon.size = TitlebarFolderIconMetrics.iconFrameSize + icon.size = NSSize(width: 16, height: 16) imageView.image = icon } @@ -454,7 +193,7 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { // Add path components (current dir at top, root at bottom - matches native macOS) for pathURL in pathComponents { let icon = NSWorkspace.shared.icon(forFile: pathURL.path) - icon.size = TitlebarFolderIconMetrics.iconFrameSize + icon.size = NSSize(width: 16, height: 16) let displayName: String if pathURL.path == "/" { @@ -478,7 +217,7 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { // Add computer name at the bottom (like native proxy icon) let computerName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName let computerIcon = NSImage(named: NSImage.computerName) ?? NSImage() - computerIcon.size = TitlebarFolderIconMetrics.iconFrameSize + computerIcon.size = NSSize(width: 16, height: 16) let computerItem = NSMenuItem(title: computerName, action: #selector(openComputer(_:)), keyEquivalent: "") computerItem.target = self @@ -497,5 +236,4 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { // Open the root filesystem entry represented by the bottom path item. NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true)) } - } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index d4adba210c64..11e4a6dc0db0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2116,6 +2116,12 @@ class GhosttyApp { prefix: "cmux-renderer-bg", logLabel: "renderer background (fallback)" ) + loadInlineGhosttyConfig( + "macos-titlebar-proxy-icon = hidden", + into: fallbackConfig, + prefix: "cmux-titlebar-proxy-icon", + logLabel: "titlebar proxy icon (fallback)" + ) loadInlineGhosttyConfig( "shell-integration = none", into: fallbackConfig, @@ -2273,6 +2279,14 @@ class GhosttyApp { prefix: "cmux-renderer-bg", logLabel: "renderer background" ) + // Hide Ghostty's native AppKit proxy icon at the source instead of + // overriding NSWindow.representedURL on every cmux main window. + loadInlineGhosttyConfig( + "macos-titlebar-proxy-icon = hidden", + into: config, + prefix: "cmux-titlebar-proxy-icon", + logLabel: "titlebar proxy icon" + ) // Save the user's preference before we force it to none. userGhosttyShellIntegrationMode = "detect" do { diff --git a/Sources/WindowChromeMetrics.swift b/Sources/WindowChromeMetrics.swift index b17f57ff7b49..6abee8ea7d93 100644 --- a/Sources/WindowChromeMetrics.swift +++ b/Sources/WindowChromeMetrics.swift @@ -14,13 +14,6 @@ enum WindowChromeMetrics { } } -enum TitlebarFolderIconMetrics { - static let iconSize: CGFloat = 16 - static let iconFrameSize = CGSize(width: iconSize, height: iconSize) - static let iconTitleSpacing: CGFloat = 8 - static let iconLeadingPadding: CGFloat = -6 -} - enum MinimalModeChromeMetrics { static let titlebarHeight: CGFloat = WindowChromeMetrics.appTitlebarHeight } diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 32c1986ae7d3..c5a4b8acc46f 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -131,40 +131,6 @@ final class WindowAccessorTests: XCTestCase { } } -@MainActor -final class CmuxMainWindowTitlebarProxyIconTests: XCTestCase { - func testMainWindowSuppressesNativeRepresentedURLProxyIcon() { - _ = NSApplication.shared - let window = CmuxMainWindow( - contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), - styleMask: [.titled, .closable, .resizable, .fullSizeContentView], - backing: .buffered, - defer: false - ) - defer { window.close() } - - let workspaceDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - window.representedURL = workspaceDirectory - - XCTAssertNil( - window.representedURL, - "cmux main windows render the workspace folder icon inside the custom titlebar, so AppKit must not keep a native proxy icon at an independent titlebar position." - ) - XCTAssertTrue( - window.representedFilename.isEmpty, - "Clearing the native proxy URL should also leave the legacy represented filename empty." - ) - - window.representedFilename = workspaceDirectory.path - - XCTAssertTrue( - window.representedFilename.isEmpty, - "cmux main windows must also reject the legacy represented filename proxy icon path." - ) - XCTAssertNil(window.representedURL) - } -} - @MainActor final class MainWindowFocusRedrawTests: XCTestCase { func testKeyRegainInvalidatesRootContentView() { @@ -1585,147 +1551,6 @@ final class WindowDragHandleHitTests: XCTestCase { @MainActor final class DraggableFolderHitTests: XCTestCase { - func testDetachedFolderPanelFollowsHostFrameChanges() { - _ = NSApplication.shared - let window = NSWindow( - contentRect: NSRect(x: 40, y: 40, width: 320, height: 180), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.close() } - let container = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 180)) - window.contentView = container - - let hostView = DetachedFolderDragIconHostView(directory: NSTemporaryDirectory()) - hostView.frame = NSRect( - x: 24, - y: 120, - width: TitlebarFolderIconMetrics.iconSize, - height: TitlebarFolderIconMetrics.iconSize - ) - container.addSubview(hostView) - defer { hostView.removeFromSuperview() } - - hostView.syncDetachedIcon() - guard let initialFrame = hostView.detachedIconFrameForTesting else { - XCTFail("Expected detached folder panel to be created") - return - } - - hostView.setFrameOrigin(NSPoint(x: 96, y: 120)) - - guard let movedFrame = hostView.detachedIconFrameForTesting else { - XCTFail("Expected detached folder panel to remain attached") - return - } - XCTAssertEqual(movedFrame.minX, initialFrame.minX + 72, accuracy: 0.5) - XCTAssertEqual(movedFrame.minY, initialFrame.minY, accuracy: 0.5) - } - - func testDetachedFolderPanelFollowsHostFrameReplacement() { - _ = NSApplication.shared - let window = NSWindow( - contentRect: NSRect(x: 40, y: 40, width: 320, height: 180), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.close() } - let container = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 180)) - window.contentView = container - - let hostView = DetachedFolderDragIconHostView(directory: NSTemporaryDirectory()) - hostView.frame = NSRect( - x: 24, - y: 120, - width: TitlebarFolderIconMetrics.iconSize, - height: TitlebarFolderIconMetrics.iconSize - ) - container.addSubview(hostView) - defer { hostView.removeFromSuperview() } - - hostView.syncDetachedIcon() - guard let initialFrame = hostView.detachedIconFrameForTesting else { - XCTFail("Expected detached folder panel to be created") - return - } - - hostView.frame = NSRect( - x: 96, - y: 128, - width: TitlebarFolderIconMetrics.iconSize, - height: TitlebarFolderIconMetrics.iconSize - ) - - guard let movedFrame = hostView.detachedIconFrameForTesting else { - XCTFail("Expected detached folder panel to remain attached") - return - } - XCTAssertEqual(movedFrame.minX, initialFrame.minX + 72, accuracy: 0.5) - XCTAssertEqual(movedFrame.minY, initialFrame.minY + 8, accuracy: 0.5) - } - - func testDetachedFolderPanelFollowsAncestorFrameChanges() { - _ = NSApplication.shared - let window = NSWindow( - contentRect: NSRect(x: 40, y: 40, width: 320, height: 180), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.close() } - let container = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 180)) - window.contentView = container - - let titlebarHost = NSView(frame: NSRect(x: 24, y: 0, width: 180, height: 180)) - container.addSubview(titlebarHost) - defer { titlebarHost.removeFromSuperview() } - - let hostView = DetachedFolderDragIconHostView(directory: NSTemporaryDirectory()) - hostView.frame = NSRect( - x: 12, - y: 120, - width: TitlebarFolderIconMetrics.iconSize, - height: TitlebarFolderIconMetrics.iconSize - ) - titlebarHost.addSubview(hostView) - defer { hostView.removeFromSuperview() } - - hostView.syncDetachedIcon() - guard let initialFrame = hostView.detachedIconFrameForTesting else { - XCTFail("Expected detached folder panel to be created") - return - } - - titlebarHost.setFrameOrigin(NSPoint(x: 96, y: 0)) - let expectedMinX = initialFrame.minX + 72 - let expectedMinY = initialFrame.minY - let deadline = Date().addingTimeInterval(0.5) - var movedFrame = hostView.detachedIconFrameForTesting - while Date() < deadline { - if let frame = movedFrame, - abs(frame.minX - expectedMinX) <= 0.5, - abs(frame.minY - expectedMinY) <= 0.5 { - break - } - RunLoop.main.run(until: Date().addingTimeInterval(0.005)) - movedFrame = hostView.detachedIconFrameForTesting - } - - guard let movedFrame else { - XCTFail("Expected detached folder panel to remain attached") - return - } - XCTAssertEqual( - movedFrame.minX, - expectedMinX, - accuracy: 0.5, - "The detached panel must follow converted window coordinates when a sidebar toggle moves an ancestor view without changing the host view's local frame." - ) - XCTAssertEqual(movedFrame.minY, expectedMinY, accuracy: 0.5) - } - func testFolderHitTestReturnsContainerWhenInsideBounds() { let folderView = DraggableFolderNSView(directory: "/tmp") folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16)