Improve terminal hosting depth and workspace mount policy
This commit is contained in:
parent
9e3f5830a8
commit
ed7f6301d0
9 changed files with 651 additions and 124 deletions
|
|
@ -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 = "<group>"; };
|
||||
A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = "<group>"; };
|
||||
A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = "<group>"; };
|
||||
A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = "<group>"; };
|
||||
A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; };
|
||||
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
|
||||
A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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<UUID> = [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<UUID> = [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<UUID> = [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<UUID> = [a, b]
|
||||
|
||||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||
current: [a, b],
|
||||
selected: nil,
|
||||
existing: existing,
|
||||
maxMounted: 0
|
||||
)
|
||||
|
||||
XCTAssertEqual(next, [a])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<UUID>,
|
||||
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<UUID> = []
|
||||
@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
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
293
Sources/TerminalWindowPortal.swift
Normal file
293
Sources/TerminalWindowPortal.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
50
tests/test_terminal_portal_hosting.py
Normal file
50
tests/test_terminal_portal_hosting.py
Normal file
|
|
@ -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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue