diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 64ef04be..bf2856d2 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; }; A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; }; + A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; }; A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; }; @@ -132,6 +133,7 @@ A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = ""; }; A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = ""; }; + A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = ""; }; A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = ""; }; @@ -304,6 +306,7 @@ A5001417 /* WorkspaceContentView.swift */, A5001014 /* GhosttyConfig.swift */, A5001015 /* GhosttyTerminalView.swift */, + A5001531 /* TerminalWindowPortal.swift */, A5001019 /* TerminalController.swift */, A5001225 /* SocketControlSettings.swift */, A5001090 /* AppDelegate.swift */, @@ -528,6 +531,7 @@ A5001407 /* WorkspaceContentView.swift in Sources */, A5001004 /* GhosttyConfig.swift in Sources */, A5001005 /* GhosttyTerminalView.swift in Sources */, + A5001532 /* TerminalWindowPortal.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, diff --git a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift index 6afb2a27..ba6ffd9e 100644 --- a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift +++ b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift @@ -1687,3 +1687,79 @@ final class MenuBarIconRendererTests: XCTestCase { XCTAssertEqual(withBadge.size.width, 18, accuracy: 0.001) } } + +final class WorkspaceMountPolicyTests: XCTestCase { + func testDefaultPolicyMountsOnlySelectedWorkspace() { + let a = UUID() + let b = UUID() + let existing: Set = [a, b] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a], + selected: b, + existing: existing + ) + + XCTAssertEqual(next, [b]) + } + + func testSelectedWorkspaceMovesToFrontAndMountCountIsBounded() { + let a = UUID() + let b = UUID() + let c = UUID() + let existing: Set = [a, b, c] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a, b, c], + selected: c, + existing: existing, + maxMounted: 2 + ) + + XCTAssertEqual(next, [c, a]) + } + + func testMissingWorkspacesArePruned() { + let a = UUID() + let b = UUID() + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [b, a], + selected: nil, + existing: [a], + maxMounted: 2 + ) + + XCTAssertEqual(next, [a]) + } + + func testSelectedWorkspaceIsInsertedWhenAbsentFromCurrentCache() { + let a = UUID() + let b = UUID() + let existing: Set = [a, b] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a], + selected: b, + existing: existing, + maxMounted: 2 + ) + + XCTAssertEqual(next, [b, a]) + } + + func testMaxMountedIsClampedToAtLeastOne() { + let a = UUID() + let b = UUID() + let existing: Set = [a, b] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a, b], + selected: nil, + existing: existing, + maxMounted: 0 + ) + + XCTAssertEqual(next, [a]) + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 26a2024b..0b594126 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1433,6 +1433,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let self else { return event } if event.type == .keyDown { #if DEBUG + if (ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1" + || UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe")), + event.timestamp > 0 { + let delayMs = max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000) + let delayText = String(format: "%.2f", delayMs) + dlog("key.latency path=appMonitor ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)") + } let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" dlog("monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")") #endif diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index beed69a2..584d8cab 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -246,8 +246,13 @@ final class FileDropOverlayView: NSView { return .copy } - /// Temporarily hides self, hit-tests the window to find the GhosttyNSView under the cursor. - private func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { + /// Hit-tests the window to find the GhosttyNSView under the cursor. + func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { + if let window, + let portalTerminal = TerminalWindowPortalRegistry.terminalViewAtWindowPoint(windowPoint, in: window) { + return portalTerminal + } + guard let window, let contentView = window.contentView, let themeFrame = contentView.superview else { return nil } isHidden = true @@ -266,6 +271,32 @@ final class FileDropOverlayView: NSView { var fileDropOverlayKey: UInt8 = 0 +enum WorkspaceMountPolicy { + // Keep only the selected workspace mounted to minimize layer-tree traversal. + static let maxMountedWorkspaces = 1 + + static func nextMountedWorkspaceIds( + current: [UUID], + selected: UUID?, + existing: Set, + maxMounted: Int = maxMountedWorkspaces + ) -> [UUID] { + let clampedMax = max(1, maxMounted) + var ordered = current.filter { existing.contains($0) } + + if let selected, existing.contains(selected) { + ordered.removeAll { $0 == selected } + ordered.insert(selected, at: 0) + } + + if ordered.count > clampedMax { + ordered.removeSubrange(clampedMax...) + } + + return ordered + } +} + /// Installs a FileDropOverlayView on the window's theme frame for Finder file drag support. func installFileDropOverlay(on window: NSWindow, tabManager: TabManager) { guard objc_getAssociatedObject(window, &fileDropOverlayKey) == nil, @@ -306,6 +337,7 @@ struct ContentView: View { @State private var isResizerDragging = false private let sidebarHandleWidth: CGFloat = 6 @State private var selectedTabIds: Set = [] + @State private var mountedWorkspaceIds: [UUID] = [] @State private var lastSidebarSelectionIndex: Int? = nil @State private var titlebarText: String = "" @State private var isFullScreen: Bool = false @@ -384,9 +416,12 @@ struct ContentView: View { @State private var titlebarPadding: CGFloat = 32 private var terminalContent: some View { - ZStack { + let mountedWorkspaceIdSet = Set(mountedWorkspaceIds) + let mountedWorkspaces = tabManager.tabs.filter { mountedWorkspaceIdSet.contains($0.id) } + + return ZStack { ZStack { - ForEach(tabManager.tabs) { tab in + ForEach(mountedWorkspaces) { tab in let isActive = tabManager.selectedTabId == tab.id WorkspaceContentView(workspace: tab, isTabActive: isActive) .opacity(isActive ? 1 : 0) @@ -554,6 +589,7 @@ struct ContentView: View { .background(Color.clear) .onAppear { tabManager.applyWindowBackgroundForSelectedTab() + reconcileMountedWorkspaceIds() if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } @@ -562,6 +598,7 @@ struct ContentView: View { } .onChange(of: tabManager.selectedTabId) { newValue in tabManager.applyWindowBackgroundForSelectedTab() + reconcileMountedWorkspaceIds(selectedId: newValue) guard let newValue else { return } if selectedTabIds.count <= 1 { selectedTabIds = [newValue] @@ -585,6 +622,7 @@ struct ContentView: View { } .onReceive(tabManager.$tabs) { tabs in let existingIds = Set(tabs.map { $0.id }) + reconcileMountedWorkspaceIds(tabs: tabs) selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] @@ -688,6 +726,17 @@ struct ContentView: View { }) } + private func reconcileMountedWorkspaceIds(tabs: [Workspace]? = nil, selectedId: UUID? = nil) { + let currentTabs = tabs ?? tabManager.tabs + let existing = Set(currentTabs.map { $0.id }) + let effectiveSelectedId = selectedId ?? tabManager.selectedTabId + mountedWorkspaceIds = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: mountedWorkspaceIds, + selected: effectiveSelectedId, + existing: existing + ) + } + private func addTab() { tabManager.addTab() sidebarSelectionState.selection = .tabs diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0ab3ad3b..20f5ac7f 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1522,6 +1522,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { var backgroundColor: NSColor? private var keySequence: [ghostty_input_trigger_s] = [] private var keyTables: [String] = [] +#if DEBUG + private static let keyLatencyProbeEnabled: Bool = { + if ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1" { + return true + } + return UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe") + }() +#endif private var eventMonitor: Any? private var trackingArea: NSTrackingArea? private var windowObserver: NSObjectProtocol? @@ -1867,6 +1875,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var markedText = NSMutableAttributedString() private var lastPerformKeyEvent: TimeInterval? +#if DEBUG + private func recordKeyLatency(path: String, event: NSEvent) { + guard Self.keyLatencyProbeEnabled else { return } + guard event.timestamp > 0 else { return } + let delayMs = max(0, (CACurrentMediaTime() - event.timestamp) * 1000) + let delayText = String(format: "%.2f", delayMs) + dlog("key.latency path=\(path) ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)") + } +#endif + // Prevents NSBeep for unimplemented actions from interpretKeyEvents override func doCommand(by selector: Selector) { // Intentionally empty - prevents system beep on unhandled key commands @@ -1877,6 +1895,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard let fr = window?.firstResponder as? NSView, fr === self || fr.isDescendant(of: self) else { return false } guard let surface = ensureSurfaceReadyForInput() else { return false } +#if DEBUG + recordKeyLatency(path: "performKeyEquivalent", event: event) +#endif #if DEBUG cmuxWriteChildExitProbe( @@ -1986,6 +2007,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { super.keyDown(with: event) return } +#if DEBUG + recordKeyLatency(path: "keyDown", event: event) +#endif #if DEBUG cmuxWriteChildExitProbe( @@ -2930,6 +2954,7 @@ final class GhosttySurfaceScrollView: NSView { func setVisibleInUI(_ visible: Bool) { surfaceView.setVisibleInUI(visible) + isHidden = !visible if !visible { // If we were focused, yield first responder. if let window, let fr = window.firstResponder as? NSView, @@ -3578,22 +3603,38 @@ struct GhosttyTerminalView: NSViewRepresentable { var onFocus: ((UUID) -> Void)? = nil var onTriggerFlash: (() -> Void)? = nil - /// SwiftUI can create NSViewRepresentable containers that are not yet inserted into a - /// window (or never inserted at all) during bonsplit structural updates. We must avoid - /// re-parenting the hosted terminal view into an off-window container, since it can get - /// "stuck" there and leave the visible terminal blank/frozen. private final class HostContainerView: NSView { var onDidMoveToWindow: (() -> Void)? + var onGeometryChanged: (() -> Void)? override func viewDidMoveToWindow() { super.viewDidMoveToWindow() - guard window != nil else { return } onDidMoveToWindow?() + onGeometryChanged?() + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + onGeometryChanged?() + } + + override func layout() { + super.layout() + onGeometryChanged?() + } + + override func setFrameOrigin(_ newOrigin: NSPoint) { + super.setFrameOrigin(newOrigin) + onGeometryChanged?() + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + onGeometryChanged?() } } final class Coordinator { - var constraints: [NSLayoutConstraint] = [] var attachGeneration: Int = 0 // Track the latest desired state so attach retries can re-apply focus after re-parenting. var desiredIsActive: Bool = true @@ -3606,57 +3647,15 @@ struct GhosttyTerminalView: NSViewRepresentable { func makeNSView(context: Context) -> NSView { let container = HostContainerView() - container.wantsLayer = true + container.wantsLayer = false return container } - private static func attachHostedView(_ hostedView: GhosttySurfaceScrollView, to host: NSView, coordinator: Coordinator) { - // Avoid implicit animations during reparenting and constraint updates. Even a single - // CoreAnimation scale/bounds animation can produce a 1-frame "blank" or stretched - // compositor frame when the IOSurface-backed layer is resized or moved. - NSAnimationContext.runAnimationGroup { ctx in - ctx.duration = 0 - ctx.allowsImplicitAnimation = false - CATransaction.begin() - CATransaction.setDisableActions(true) - defer { CATransaction.commit() } - - // Remove any stale content views in the host, but avoid unnecessarily removing - // the hosted terminal view if it is already attached. - for v in host.subviews where v !== hostedView { - v.removeFromSuperview() - } - - if hostedView.superview !== host { - hostedView.removeFromSuperview() - host.addSubview(hostedView) - } - - hostedView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.deactivate(coordinator.constraints) - coordinator.constraints = [ - hostedView.leadingAnchor.constraint(equalTo: host.leadingAnchor), - hostedView.trailingAnchor.constraint(equalTo: host.trailingAnchor), - hostedView.topAnchor.constraint(equalTo: host.topAnchor), - hostedView.bottomAnchor.constraint(equalTo: host.bottomAnchor), - ] - NSLayoutConstraint.activate(coordinator.constraints) - host.needsLayout = true - host.layoutSubtreeIfNeeded() - } - - // Re-apply visible/active state after re-parenting so focus/occlusion requests run with - // a valid window. - // Without this, a focus attempt issued while the hosted view is off-window can time out, - // leaving the visible terminal unfocused (keys appear to go to the wrong surface). - hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) - hostedView.setActive(coordinator.desiredIsActive) - } - func updateNSView(_ nsView: NSView, context: Context) { let hostedView = terminalSurface.hostedView - context.coordinator.desiredIsActive = isActive - context.coordinator.desiredIsVisibleInUI = isVisibleInUI + let coordinator = context.coordinator + coordinator.desiredIsActive = isActive + coordinator.desiredIsVisibleInUI = isVisibleInUI // Keep the surface lifecycle and handlers updated even if we defer re-parenting. hostedView.attachSurface(terminalSurface) @@ -3665,52 +3664,52 @@ struct GhosttyTerminalView: NSViewRepresentable { hostedView.setFocusHandler { onFocus?(terminalSurface.id) } hostedView.setTriggerFlashHandler(onTriggerFlash) - if hostedView.superview !== nsView { - context.coordinator.attachGeneration += 1 - let generation = context.coordinator.attachGeneration + coordinator.attachGeneration += 1 + let generation = coordinator.attachGeneration - // If this container isn't in a window yet, defer attaching until it is. - // Importantly: do NOT detach the hosted view from its current superview - // until we have a valid window, otherwise it can disappear and become - // "stuck" in an off-window container. - if let host = nsView as? HostContainerView { - host.onDidMoveToWindow = { [weak coordinator = context.coordinator, weak host, weak hostedView] in - guard let coordinator, coordinator.attachGeneration == generation else { return } - guard let host, let hostedView else { return } - guard host.window != nil else { return } - Self.attachHostedView(hostedView, to: host, coordinator: coordinator) - } + if let host = nsView as? HostContainerView { + host.onDidMoveToWindow = { [weak host, weak hostedView, weak coordinator] in + guard let host, let hostedView, let coordinator else { return } + guard coordinator.attachGeneration == generation else { return } + guard host.window != nil else { return } + TerminalWindowPortalRegistry.bind( + hostedView: hostedView, + to: host, + visibleInUI: coordinator.desiredIsVisibleInUI + ) + hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) + hostedView.setActive(coordinator.desiredIsActive) + } + host.onGeometryChanged = { [weak host, weak hostedView, weak coordinator] in + guard let host, let hostedView, let coordinator else { return } + guard coordinator.attachGeneration == generation else { return } + TerminalWindowPortalRegistry.bind( + hostedView: hostedView, + to: host, + visibleInUI: coordinator.desiredIsVisibleInUI + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(host) } - if nsView.window != nil { - Self.attachHostedView(hostedView, to: nsView, coordinator: context.coordinator) - } - } else { - context.coordinator.attachGeneration += 1 - if let host = nsView as? HostContainerView { - host.onDidMoveToWindow = nil + if host.window != nil { + TerminalWindowPortalRegistry.bind( + hostedView: hostedView, + to: host, + visibleInUI: coordinator.desiredIsVisibleInUI + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(host) } } - } static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.attachGeneration += 1 - NSLayoutConstraint.deactivate(coordinator.constraints) - coordinator.constraints.removeAll() - if let host = nsView as? HostContainerView { host.onDidMoveToWindow = nil + host.onGeometryChanged = nil } - // Avoid proactively detaching the hosted terminal view during SwiftUI structural updates. - // When bonsplit rearranges panes, SwiftUI can dismantle the "old" container before the - // "new" container has re-parented the hosted view; removing it here creates a visible - // transient blank (and can strand the view off-window if the re-attach is missed). - let hasHostedTerminal = nsView.subviews.contains(where: { $0 is GhosttySurfaceScrollView }) - if !hasHostedTerminal { - nsView.subviews.forEach { $0.removeFromSuperview() } - } + nsView.subviews.forEach { $0.removeFromSuperview() } } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index c207d230..ac33bd0e 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -6291,6 +6291,10 @@ class TerminalController { guard let parsed = parseShortcutCombo(combo) else { return "ERROR: Invalid combo. Example: cmd+ctrl+h" } + + // Stamp at socket-handler arrival so event.timestamp includes any wait + // before the main-thread event dispatch. + let requestTimestamp = ProcessInfo.processInfo.systemUptime var result = "ERROR: Failed to create event" DispatchQueue.main.sync { @@ -6305,11 +6309,11 @@ class TerminalController { targetWindow.makeKeyAndOrderFront(nil) } let windowNumber = (NSApp.keyWindow ?? targetWindow)?.windowNumber ?? 0 - guard let event = NSEvent.keyEvent( + guard let keyDownEvent = NSEvent.keyEvent( with: .keyDown, location: .zero, modifierFlags: parsed.modifierFlags, - timestamp: ProcessInfo.processInfo.systemUptime, + timestamp: requestTimestamp, windowNumber: windowNumber, context: nil, characters: parsed.characters, @@ -6320,14 +6324,29 @@ class TerminalController { result = "ERROR: NSEvent.keyEvent returned nil" return } + let keyUpEvent = NSEvent.keyEvent( + with: .keyUp, + location: .zero, + modifierFlags: parsed.modifierFlags, + timestamp: requestTimestamp + 0.0001, + windowNumber: windowNumber, + context: nil, + characters: parsed.characters, + charactersIgnoringModifiers: parsed.charactersIgnoringModifiers, + isARepeat: false, + keyCode: parsed.keyCode + ) // Socket-driven shortcut simulation should reuse the exact same matching logic as the // app-level shortcut monitor (so tests are hermetic), while still falling back to the // normal responder chain for plain typing. - if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: event) { + if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: keyDownEvent) { result = "OK" return } - NSApp.sendEvent(event) + NSApp.sendEvent(keyDownEvent) + if let keyUpEvent { + NSApp.sendEvent(keyUpEvent) + } result = "OK" } return result @@ -6447,34 +6466,20 @@ class TerminalController { let contentView = window.contentView, let themeFrame = contentView.superview else { return } - // Compute the point in contentView's own coordinate system. - // NSHostingView is flipped: (0,0) = top-left, matching our API. - let contentPoint = NSPoint( - x: contentView.bounds.width * nx, - y: contentView.bounds.height * ny + // Convert normalized top-left coordinates into a window point. + let pointInTheme = NSPoint( + x: contentView.frame.minX + (contentView.bounds.width * nx), + y: contentView.frame.maxY - (contentView.bounds.height * ny) ) + let windowPoint = themeFrame.convert(pointInTheme, to: nil) - // hitTest expects the point in the receiver's superview's (themeFrame's) - // coordinate system. Use convert to handle the coordinate transform. - let hitPoint = contentView.convert(contentPoint, to: themeFrame) - - // Temporarily hide the overlay so it doesn't intercept the hit test. - let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? NSView - overlay?.isHidden = true - - let hitView = contentView.hitTest(hitPoint) - - overlay?.isHidden = false - - var current: NSView? = hitView - while let view = current { - if let terminal = view as? GhosttyNSView, - let surfaceId = terminal.terminalSurface?.id { - result = surfaceId.uuidString.uppercased() - return - } - current = view.superview + if let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? FileDropOverlayView, + let terminal = overlay.terminalUnderPoint(windowPoint), + let surfaceId = terminal.terminalSurface?.id { + result = surfaceId.uuidString.uppercased() + return } + result = "none" } return result @@ -9205,6 +9210,25 @@ class TerminalController { return "OK Refreshed \(refreshedCount) surfaces" } + private func viewDepth(of view: NSView, maxDepth: Int = 128) -> Int { + var depth = 0 + var current: NSView? = view + while let v = current, depth < maxDepth { + current = v.superview + depth += 1 + } + return depth + } + + private func isPortalHosted(_ view: NSView) -> Bool { + var current: NSView? = view + while let v = current { + if v is WindowTerminalHostView { return true } + current = v.superview + } + return false + } + private func surfaceHealth(_ tabArg: String) -> String { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } var result = "" @@ -9219,7 +9243,9 @@ class TerminalController { let type = panel.panelType.rawValue if let tp = panel as? TerminalPanel { let inWindow = tp.surface.isViewInWindow - return "\(index): \(panelId) type=\(type) in_window=\(inWindow)" + let portalHosted = isPortalHosted(tp.hostedView) + let depth = viewDepth(of: tp.hostedView) + return "\(index): \(panelId) type=\(type) in_window=\(inWindow) portal=\(portalHosted) view_depth=\(depth)" } else if let bp = panel as? BrowserPanel { let inWindow = bp.webView.window != nil return "\(index): \(panelId) type=\(type) in_window=\(inWindow)" diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift new file mode 100644 index 00000000..6e70c32a --- /dev/null +++ b/Sources/TerminalWindowPortal.swift @@ -0,0 +1,293 @@ +import AppKit +import ObjectiveC + +private var cmuxWindowTerminalPortalKey: UInt8 = 0 + +final class WindowTerminalHostView: NSView { + override var isOpaque: Bool { false } +} + +@MainActor +final class WindowTerminalPortal: NSObject { + private weak var window: NSWindow? + private let hostView = WindowTerminalHostView(frame: .zero) + private weak var installedContainerView: NSView? + private weak var installedReferenceView: NSView? + private var installConstraints: [NSLayoutConstraint] = [] + + private struct Entry { + weak var hostedView: GhosttySurfaceScrollView? + weak var anchorView: NSView? + var visibleInUI: Bool + } + + private var entriesByHostedId: [ObjectIdentifier: Entry] = [:] + private var hostedByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:] + + init(window: NSWindow) { + self.window = window + super.init() + hostView.wantsLayer = false + hostView.translatesAutoresizingMaskIntoConstraints = false + _ = ensureInstalled() + } + + @discardableResult + private func ensureInstalled() -> Bool { + guard let window else { return false } + guard let (container, reference) = installationTarget(for: window) else { return false } + + if hostView.superview !== container || + installedContainerView !== container || + installedReferenceView !== reference { + NSLayoutConstraint.deactivate(installConstraints) + installConstraints.removeAll() + + hostView.removeFromSuperview() + container.addSubview(hostView, positioned: .above, relativeTo: reference) + + installConstraints = [ + hostView.leadingAnchor.constraint(equalTo: reference.leadingAnchor), + hostView.trailingAnchor.constraint(equalTo: reference.trailingAnchor), + hostView.topAnchor.constraint(equalTo: reference.topAnchor), + hostView.bottomAnchor.constraint(equalTo: reference.bottomAnchor), + ] + NSLayoutConstraint.activate(installConstraints) + installedContainerView = container + installedReferenceView = reference + } else { + container.addSubview(hostView, positioned: .above, relativeTo: reference) + } + + // Keep the drag/mouse forwarding overlay above portal-hosted terminal views. + if let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? NSView, + overlay.superview === container { + container.addSubview(overlay, positioned: .above, relativeTo: hostView) + } + + return true + } + + private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? { + guard let contentView = window.contentView else { return nil } + + // If NSGlassEffectView wraps the original content view, install inside the glass view + // so terminals are above the glass background but below SwiftUI content. + if contentView.className == "NSGlassEffectView", + let foreground = contentView.subviews.first(where: { $0 !== hostView }) { + return (contentView, foreground) + } + + guard let themeFrame = contentView.superview else { return nil } + return (themeFrame, contentView) + } + + private static func isHiddenOrAncestorHidden(_ view: NSView) -> Bool { + if view.isHidden { return true } + var current = view.superview + while let v = current { + if v.isHidden { return true } + current = v.superview + } + return false + } + + private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { + abs(lhs.origin.x - rhs.origin.x) <= epsilon && + abs(lhs.origin.y - rhs.origin.y) <= epsilon && + abs(lhs.size.width - rhs.size.width) <= epsilon && + abs(lhs.size.height - rhs.size.height) <= epsilon + } + + func detachHostedView(withId hostedId: ObjectIdentifier) { + guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return } + if let anchor = entry.anchorView { + hostedByAnchorId.removeValue(forKey: ObjectIdentifier(anchor)) + } + if let hostedView = entry.hostedView, hostedView.superview === hostView { + hostedView.removeFromSuperview() + } + } + + func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool) { + guard ensureInstalled() else { return } + + let hostedId = ObjectIdentifier(hostedView) + let anchorId = ObjectIdentifier(anchorView) + + if let previousHostedId = hostedByAnchorId[anchorId], previousHostedId != hostedId { + detachHostedView(withId: previousHostedId) + } + + if let oldEntry = entriesByHostedId[hostedId], + let oldAnchor = oldEntry.anchorView, + oldAnchor !== anchorView { + hostedByAnchorId.removeValue(forKey: ObjectIdentifier(oldAnchor)) + } + + hostedByAnchorId[anchorId] = hostedId + entriesByHostedId[hostedId] = Entry( + hostedView: hostedView, + anchorView: anchorView, + visibleInUI: visibleInUI + ) + + if hostedView.superview !== hostView { + hostedView.removeFromSuperview() + hostView.addSubview(hostedView) + } + + synchronizeHostedView(withId: hostedId) + pruneDeadEntries() + } + + func synchronizeHostedViewForAnchor(_ anchorView: NSView) { + guard let hostedId = hostedByAnchorId[ObjectIdentifier(anchorView)] else { return } + synchronizeHostedView(withId: hostedId) + } + + private func synchronizeHostedView(withId hostedId: ObjectIdentifier) { + guard ensureInstalled() else { return } + guard let entry = entriesByHostedId[hostedId] else { return } + guard let hostedView = entry.hostedView else { + entriesByHostedId.removeValue(forKey: hostedId) + return + } + guard let anchorView = entry.anchorView, let window else { + hostedView.isHidden = true + return + } + guard anchorView.window === window else { + hostedView.isHidden = true + return + } + + let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) + let frameInHost = hostView.convert(frameInWindow, from: nil) + let shouldHide = + !entry.visibleInUI || + Self.isHiddenOrAncestorHidden(anchorView) || + frameInHost.width <= 1 || + frameInHost.height <= 1 + + let oldFrame = hostedView.frame + if !Self.rectApproximatelyEqual(oldFrame, frameInHost) { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostedView.frame = frameInHost + CATransaction.commit() + + if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 || + abs(oldFrame.size.height - frameInHost.size.height) > 0.5 { + hostedView.reconcileGeometryNow() + } + } + + if hostedView.isHidden != shouldHide { + hostedView.isHidden = shouldHide + } + } + + private func pruneDeadEntries() { + let deadHostedIds = entriesByHostedId.compactMap { hostedId, entry -> ObjectIdentifier? in + if entry.hostedView == nil { + if let anchor = entry.anchorView { + hostedByAnchorId.removeValue(forKey: ObjectIdentifier(anchor)) + } + return hostedId + } + if entry.anchorView == nil { + entry.hostedView?.isHidden = true + } + return nil + } + + for hostedId in deadHostedIds { + entriesByHostedId.removeValue(forKey: hostedId) + } + + let validAnchorIds = Set(entriesByHostedId.compactMap { _, entry in + entry.anchorView.map { ObjectIdentifier($0) } + }) + hostedByAnchorId = hostedByAnchorId.filter { validAnchorIds.contains($0.key) } + } + + func viewAtWindowPoint(_ windowPoint: NSPoint) -> NSView? { + guard ensureInstalled() else { return nil } + let point = hostView.convert(windowPoint, from: nil) + + // Restrict hit-testing to currently mapped entries so stale detached views + // can't steal file-drop/mouse routing. + for subview in hostView.subviews.reversed() { + guard let hostedView = subview as? GhosttySurfaceScrollView else { continue } + let hostedId = ObjectIdentifier(hostedView) + guard entriesByHostedId[hostedId] != nil else { continue } + guard !hostedView.isHidden else { continue } + guard hostedView.frame.contains(point) else { continue } + let localPoint = hostedView.convert(point, from: hostView) + return hostedView.hitTest(localPoint) ?? hostedView + } + + return nil + } + + func terminalViewAtWindowPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { + guard let hitView = viewAtWindowPoint(windowPoint) else { return nil } + var current: NSView? = hitView + while let view = current { + if let terminal = view as? GhosttyNSView { return terminal } + current = view.superview + } + return nil + } +} + +@MainActor +enum TerminalWindowPortalRegistry { + private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:] + private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] + + private static func portal(for window: NSWindow) -> WindowTerminalPortal { + if let existing = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalKey) as? WindowTerminalPortal { + portalsByWindowId[ObjectIdentifier(window)] = existing + return existing + } + + let portal = WindowTerminalPortal(window: window) + objc_setAssociatedObject(window, &cmuxWindowTerminalPortalKey, portal, .OBJC_ASSOCIATION_RETAIN) + portalsByWindowId[ObjectIdentifier(window)] = portal + return portal + } + + static func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool) { + guard let window = anchorView.window else { return } + + let windowId = ObjectIdentifier(window) + let hostedId = ObjectIdentifier(hostedView) + let nextPortal = portal(for: window) + + if let oldWindowId = hostedToWindowId[hostedId], + oldWindowId != windowId { + portalsByWindowId[oldWindowId]?.detachHostedView(withId: hostedId) + } + + nextPortal.bind(hostedView: hostedView, to: anchorView, visibleInUI: visibleInUI) + hostedToWindowId[hostedId] = windowId + } + + static func synchronizeForAnchor(_ anchorView: NSView) { + guard let window = anchorView.window else { return } + let portal = portal(for: window) + portal.synchronizeHostedViewForAnchor(anchorView) + } + + static func viewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> NSView? { + let portal = portal(for: window) + return portal.viewAtWindowPoint(windowPoint) + } + + static func terminalViewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> GhosttyNSView? { + let portal = portal(for: window) + return portal.terminalViewAtWindowPoint(windowPoint) + } +} diff --git a/tests/cmux.py b/tests/cmux.py index 22d61fa1..d33025b6 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -997,7 +997,8 @@ class cmux: def surface_health(self, workspace: Union[str, int, None] = None) -> List[dict]: """ Check view health of all surfaces in a workspace. - Returns list of dicts with keys: index, id, type, in_window. + Returns list of dicts with keys: index, id, type, in_window, plus any + extra key=value fields returned by the daemon. """ arg = "" if workspace is None else str(workspace) response = self._send_command(f"surface_health {arg}".rstrip()) @@ -1013,14 +1014,36 @@ class cmux: continue index = int(parts[0].rstrip(":")) surface_id = parts[1] - panel_type = parts[2].split("=", 1)[1] if "=" in parts[2] else "unknown" - in_window = parts[3].split("=", 1)[1] == "true" if "=" in parts[3] else False - surfaces.append({ + kv: dict[str, str] = {} + for token in parts[2:]: + if "=" not in token: + continue + key, value = token.split("=", 1) + kv[key] = value + + panel_type = kv.get("type", "unknown") + in_window = kv.get("in_window", "false") == "true" + + row: dict = { "index": index, "id": surface_id, "type": panel_type, "in_window": in_window, - }) + } + + for key, value in kv.items(): + if key in {"type", "in_window"}: + continue + if value == "true": + row[key] = True + elif value == "false": + row[key] = False + elif value.isdigit() or (value.startswith("-") and value[1:].isdigit()): + row[key] = int(value) + else: + row[key] = value + + surfaces.append(row) return surfaces diff --git a/tests/test_terminal_portal_hosting.py b/tests/test_terminal_portal_hosting.py new file mode 100644 index 00000000..911a95b3 --- /dev/null +++ b/tests/test_terminal_portal_hosting.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Regression: terminal views should be portal-hosted near the window root. + +This catches regressions where terminal NSViews are reattached deep inside the SwiftUI +hierarchy, which increases Core Animation commit traversal depth and input latency. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def main() -> int: + with cmux(SOCKET_PATH) as c: + c.activate_app() + + c.new_workspace() + time.sleep(0.2) + c.new_split("right") + time.sleep(0.8) + + health = c.surface_health() + terminals = [row for row in health if row.get("type") == "terminal"] + if len(terminals) < 2: + raise cmuxError(f"expected >=2 terminal surfaces after split, got={terminals}") + + for row in terminals: + if not row.get("in_window", False): + raise cmuxError(f"terminal not attached to window: {row}") + if row.get("portal") is not True: + raise cmuxError(f"terminal is not portal-hosted: {row}") + depth = row.get("view_depth") + if not isinstance(depth, int): + raise cmuxError(f"missing view_depth in surface_health: {row}") + if depth > 8: + raise cmuxError(f"terminal view depth too deep ({depth}): {row}") + + print("PASS: terminal surfaces are portal-hosted with shallow view depth") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())