Speed up workspace switching: reduce portal churn and enforce selected z-order
This commit is contained in:
parent
442eb1f01d
commit
ee4848c008
7 changed files with 240 additions and 46 deletions
|
|
@ -274,7 +274,8 @@ var fileDropOverlayKey: UInt8 = 0
|
|||
enum WorkspaceMountPolicy {
|
||||
// Keep only the selected workspace mounted to minimize layer-tree traversal.
|
||||
static let maxMountedWorkspaces = 1
|
||||
static let maxMountedWorkspacesDuringCycle = 3
|
||||
// During workspace cycling, keep only a minimal handoff pair (selected + retiring).
|
||||
static let maxMountedWorkspacesDuringCycle = 2
|
||||
|
||||
static func nextMountedWorkspaceIds(
|
||||
current: [UUID],
|
||||
|
|
@ -293,12 +294,6 @@ enum WorkspaceMountPolicy {
|
|||
ordered.insert(selected, at: 0)
|
||||
}
|
||||
|
||||
let prioritizedPinnedIds = pinnedIds.filter { existing.contains($0) && $0 != selected }
|
||||
for pinnedId in prioritizedPinnedIds.reversed() {
|
||||
ordered.removeAll { $0 == pinnedId }
|
||||
ordered.insert(pinnedId, at: 0)
|
||||
}
|
||||
|
||||
if isCycleHot, let selected {
|
||||
let warmIds = cycleWarmIds(selected: selected, orderedTabIds: orderedTabIds)
|
||||
for id in warmIds.reversed() {
|
||||
|
|
@ -307,6 +302,33 @@ enum WorkspaceMountPolicy {
|
|||
}
|
||||
}
|
||||
|
||||
if isCycleHot,
|
||||
pinnedIds.isEmpty,
|
||||
let selected {
|
||||
ordered.removeAll { $0 != selected }
|
||||
}
|
||||
|
||||
// Ensure pinned ids (retiring handoff workspaces) are always retained at highest priority.
|
||||
// This runs after warming to prevent neighbor warming from evicting the retiring workspace.
|
||||
let prioritizedPinnedIds = pinnedIds
|
||||
.filter { existing.contains($0) && $0 != selected }
|
||||
.sorted { lhs, rhs in
|
||||
let lhsIndex = orderedTabIds.firstIndex(of: lhs) ?? .max
|
||||
let rhsIndex = orderedTabIds.firstIndex(of: rhs) ?? .max
|
||||
return lhsIndex < rhsIndex
|
||||
}
|
||||
if let selected, existing.contains(selected) {
|
||||
ordered.removeAll { $0 == selected }
|
||||
ordered.insert(selected, at: 0)
|
||||
}
|
||||
var pinnedInsertionIndex = (selected != nil) ? 1 : 0
|
||||
for pinnedId in prioritizedPinnedIds {
|
||||
ordered.removeAll { $0 == pinnedId }
|
||||
let insertionIndex = min(pinnedInsertionIndex, ordered.count)
|
||||
ordered.insert(pinnedId, at: insertionIndex)
|
||||
pinnedInsertionIndex += 1
|
||||
}
|
||||
|
||||
if ordered.count > clampedMax {
|
||||
ordered.removeSubrange(clampedMax...)
|
||||
}
|
||||
|
|
@ -315,18 +337,10 @@ enum WorkspaceMountPolicy {
|
|||
}
|
||||
|
||||
private static func cycleWarmIds(selected: UUID, orderedTabIds: [UUID]) -> [UUID] {
|
||||
guard let selectedIndex = orderedTabIds.firstIndex(of: selected) else {
|
||||
return [selected]
|
||||
}
|
||||
|
||||
var ids: [UUID] = [selected]
|
||||
if selectedIndex > 0 {
|
||||
ids.append(orderedTabIds[selectedIndex - 1])
|
||||
}
|
||||
if selectedIndex + 1 < orderedTabIds.count {
|
||||
ids.append(orderedTabIds[selectedIndex + 1])
|
||||
}
|
||||
return ids
|
||||
guard orderedTabIds.contains(selected) else { return [selected] }
|
||||
// Keep warming focused to the selected workspace. Retiring/target workspaces are
|
||||
// pinned by handoff logic, so warming adjacent neighbors here just adds layout work.
|
||||
return [selected]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -465,10 +479,12 @@ struct ContentView: View {
|
|||
let isRetiringWorkspace = retiringWorkspaceId == tab.id
|
||||
let isInputActive = isSelectedWorkspace || isRetiringWorkspace
|
||||
let isVisible = isSelectedWorkspace || isRetiringWorkspace
|
||||
let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)
|
||||
WorkspaceContentView(
|
||||
workspace: tab,
|
||||
isWorkspaceVisible: isVisible,
|
||||
isWorkspaceInputActive: isInputActive
|
||||
isWorkspaceInputActive: isInputActive,
|
||||
workspacePortalPriority: portalPriority
|
||||
)
|
||||
.opacity(isVisible ? 1 : 0)
|
||||
.allowsHitTesting(isSelectedWorkspace)
|
||||
|
|
@ -821,7 +837,8 @@ struct ContentView: View {
|
|||
let effectiveSelectedId = selectedId ?? tabManager.selectedTabId
|
||||
let pinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? []
|
||||
let isCycleHot = tabManager.isWorkspaceCycleHot
|
||||
let baseMaxMounted = isCycleHot
|
||||
let shouldKeepHandoffPair = isCycleHot && !pinnedIds.isEmpty
|
||||
let baseMaxMounted = shouldKeepHandoffPair
|
||||
? WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
|
||||
: WorkspaceMountPolicy.maxMountedWorkspaces
|
||||
let selectedCount = effectiveSelectedId == nil ? 0 : 1
|
||||
|
|
|
|||
|
|
@ -3670,6 +3670,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
let terminalSurface: TerminalSurface
|
||||
var isActive: Bool = true
|
||||
var isVisibleInUI: Bool = true
|
||||
var portalZPriority: Int = 0
|
||||
var showsInactiveOverlay: Bool = false
|
||||
var inactiveOverlayColor: NSColor = .clear
|
||||
var inactiveOverlayOpacity: Double = 0
|
||||
|
|
@ -3713,6 +3714,8 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
// Track the latest desired state so attach retries can re-apply focus after re-parenting.
|
||||
var desiredIsActive: Bool = true
|
||||
var desiredIsVisibleInUI: Bool = true
|
||||
var desiredPortalZPriority: Int = 0
|
||||
var lastBoundHostId: ObjectIdentifier?
|
||||
weak var hostedView: GhosttySurfaceScrollView?
|
||||
}
|
||||
|
||||
|
|
@ -3729,23 +3732,30 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
let hostedView = terminalSurface.hostedView
|
||||
let coordinator = context.coordinator
|
||||
#if DEBUG
|
||||
let previousDesiredIsActive = coordinator.desiredIsActive
|
||||
#endif
|
||||
let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI
|
||||
let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority
|
||||
coordinator.desiredIsActive = isActive
|
||||
coordinator.desiredIsVisibleInUI = isVisibleInUI
|
||||
coordinator.desiredPortalZPriority = portalZPriority
|
||||
coordinator.hostedView = hostedView
|
||||
#if DEBUG
|
||||
if previousDesiredIsActive != isActive || previousDesiredIsVisibleInUI != isVisibleInUI {
|
||||
if previousDesiredIsActive != isActive ||
|
||||
previousDesiredIsVisibleInUI != isVisibleInUI ||
|
||||
previousDesiredPortalZPriority != portalZPriority {
|
||||
if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() {
|
||||
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||||
dlog(
|
||||
"ws.swiftui.update id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " +
|
||||
"surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)"
|
||||
"surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) " +
|
||||
"active=\(isActive ? 1 : 0) z=\(portalZPriority)"
|
||||
)
|
||||
} else {
|
||||
dlog(
|
||||
"ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " +
|
||||
"visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)"
|
||||
"visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -3774,28 +3784,36 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
TerminalWindowPortalRegistry.bind(
|
||||
hostedView: hostedView,
|
||||
to: host,
|
||||
visibleInUI: coordinator.desiredIsVisibleInUI
|
||||
visibleInUI: coordinator.desiredIsVisibleInUI,
|
||||
zPriority: coordinator.desiredPortalZPriority
|
||||
)
|
||||
coordinator.lastBoundHostId = ObjectIdentifier(host)
|
||||
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 }
|
||||
host.onGeometryChanged = { [weak host, weak coordinator] in
|
||||
guard let host, let coordinator else { return }
|
||||
guard coordinator.attachGeneration == generation else { return }
|
||||
TerminalWindowPortalRegistry.bind(
|
||||
hostedView: hostedView,
|
||||
to: host,
|
||||
visibleInUI: coordinator.desiredIsVisibleInUI
|
||||
)
|
||||
guard coordinator.lastBoundHostId == ObjectIdentifier(host) else { return }
|
||||
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
|
||||
}
|
||||
|
||||
if host.window != nil {
|
||||
TerminalWindowPortalRegistry.bind(
|
||||
hostedView: hostedView,
|
||||
to: host,
|
||||
visibleInUI: coordinator.desiredIsVisibleInUI
|
||||
)
|
||||
let hostId = ObjectIdentifier(host)
|
||||
let shouldBindNow =
|
||||
coordinator.lastBoundHostId != hostId ||
|
||||
hostedView.superview == nil ||
|
||||
previousDesiredIsVisibleInUI != isVisibleInUI ||
|
||||
previousDesiredPortalZPriority != portalZPriority
|
||||
if shouldBindNow {
|
||||
TerminalWindowPortalRegistry.bind(
|
||||
hostedView: hostedView,
|
||||
to: host,
|
||||
visibleInUI: coordinator.desiredIsVisibleInUI,
|
||||
zPriority: coordinator.desiredPortalZPriority
|
||||
)
|
||||
coordinator.lastBoundHostId = hostId
|
||||
}
|
||||
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
|
||||
}
|
||||
}
|
||||
|
|
@ -3805,6 +3823,8 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
coordinator.attachGeneration += 1
|
||||
coordinator.desiredIsActive = false
|
||||
coordinator.desiredIsVisibleInUI = false
|
||||
coordinator.desiredPortalZPriority = 0
|
||||
coordinator.lastBoundHostId = nil
|
||||
#if DEBUG
|
||||
if let hostedView = coordinator.hostedView {
|
||||
if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ struct PanelContentView: View {
|
|||
let isFocused: Bool
|
||||
let isSelectedInPane: Bool
|
||||
let isVisibleInUI: Bool
|
||||
let portalPriority: Int
|
||||
let isSplit: Bool
|
||||
let appearance: PanelAppearance
|
||||
let notificationStore: TerminalNotificationStore
|
||||
|
|
@ -22,6 +23,7 @@ struct PanelContentView: View {
|
|||
panel: terminalPanel,
|
||||
isFocused: isFocused,
|
||||
isVisibleInUI: isVisibleInUI,
|
||||
portalPriority: portalPriority,
|
||||
isSplit: isSplit,
|
||||
appearance: appearance,
|
||||
notificationStore: notificationStore,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ struct TerminalPanelView: View {
|
|||
@ObservedObject var panel: TerminalPanel
|
||||
let isFocused: Bool
|
||||
let isVisibleInUI: Bool
|
||||
let portalPriority: Int
|
||||
let isSplit: Bool
|
||||
let appearance: PanelAppearance
|
||||
let notificationStore: TerminalNotificationStore
|
||||
|
|
@ -19,6 +20,7 @@ struct TerminalPanelView: View {
|
|||
terminalSurface: panel.surface,
|
||||
isActive: isFocused,
|
||||
isVisibleInUI: isVisibleInUI,
|
||||
portalZPriority: portalPriority,
|
||||
showsInactiveOverlay: isSplit && !isFocused,
|
||||
inactiveOverlayColor: appearance.unfocusedOverlayNSColor,
|
||||
inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ final class WindowTerminalPortal: NSObject {
|
|||
weak var hostedView: GhosttySurfaceScrollView?
|
||||
weak var anchorView: NSView?
|
||||
var visibleInUI: Bool
|
||||
var zPriority: Int
|
||||
}
|
||||
|
||||
private var entriesByHostedId: [ObjectIdentifier: Entry] = [:]
|
||||
|
|
@ -61,13 +62,14 @@ final class WindowTerminalPortal: NSObject {
|
|||
NSLayoutConstraint.activate(installConstraints)
|
||||
installedContainerView = container
|
||||
installedReferenceView = reference
|
||||
} else {
|
||||
} else if !Self.isView(hostView, above: reference, in: container) {
|
||||
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 {
|
||||
overlay.superview === container,
|
||||
!Self.isView(overlay, above: hostView, in: container) {
|
||||
container.addSubview(overlay, positioned: .above, relativeTo: hostView)
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +107,14 @@ final class WindowTerminalPortal: NSObject {
|
|||
abs(lhs.size.height - rhs.size.height) <= epsilon
|
||||
}
|
||||
|
||||
private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool {
|
||||
guard let viewIndex = container.subviews.firstIndex(of: view),
|
||||
let referenceIndex = container.subviews.firstIndex(of: reference) else {
|
||||
return false
|
||||
}
|
||||
return viewIndex > referenceIndex
|
||||
}
|
||||
|
||||
func detachHostedView(withId hostedId: ObjectIdentifier) {
|
||||
guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return }
|
||||
if let anchor = entry.anchorView {
|
||||
|
|
@ -115,11 +125,12 @@ final class WindowTerminalPortal: NSObject {
|
|||
}
|
||||
}
|
||||
|
||||
func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool) {
|
||||
func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
|
||||
guard ensureInstalled() else { return }
|
||||
|
||||
let hostedId = ObjectIdentifier(hostedView)
|
||||
let anchorId = ObjectIdentifier(anchorView)
|
||||
let previousEntry = entriesByHostedId[hostedId]
|
||||
|
||||
if let previousHostedId = hostedByAnchorId[anchorId], previousHostedId != hostedId {
|
||||
detachHostedView(withId: previousHostedId)
|
||||
|
|
@ -135,12 +146,25 @@ final class WindowTerminalPortal: NSObject {
|
|||
entriesByHostedId[hostedId] = Entry(
|
||||
hostedView: hostedView,
|
||||
anchorView: anchorView,
|
||||
visibleInUI: visibleInUI
|
||||
visibleInUI: visibleInUI,
|
||||
zPriority: zPriority
|
||||
)
|
||||
|
||||
let didChangeAnchor: Bool = {
|
||||
guard let previousAnchor = previousEntry?.anchorView else { return true }
|
||||
return previousAnchor !== anchorView
|
||||
}()
|
||||
let becameVisible = (previousEntry?.visibleInUI ?? false) == false && visibleInUI
|
||||
let priorityIncreased = zPriority > (previousEntry?.zPriority ?? Int.min)
|
||||
|
||||
if hostedView.superview !== hostView {
|
||||
hostedView.removeFromSuperview()
|
||||
hostView.addSubview(hostedView)
|
||||
} else if (didChangeAnchor || becameVisible || priorityIncreased), hostView.subviews.last !== hostedView {
|
||||
// Refresh z-order only on meaningful transitions. Reordering on every bind call
|
||||
// creates expensive reparent loops during SwiftUI update/layout churn.
|
||||
hostedView.removeFromSuperview()
|
||||
hostView.addSubview(hostedView)
|
||||
}
|
||||
|
||||
synchronizeHostedView(withId: hostedId)
|
||||
|
|
@ -347,7 +371,7 @@ enum TerminalWindowPortalRegistry {
|
|||
return portal
|
||||
}
|
||||
|
||||
static func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool) {
|
||||
static func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
|
||||
guard let window = anchorView.window else { return }
|
||||
|
||||
let windowId = ObjectIdentifier(window)
|
||||
|
|
@ -359,7 +383,7 @@ enum TerminalWindowPortalRegistry {
|
|||
portalsByWindowId[oldWindowId]?.detachHostedView(withId: hostedId)
|
||||
}
|
||||
|
||||
nextPortal.bind(hostedView: hostedView, to: anchorView, visibleInUI: visibleInUI)
|
||||
nextPortal.bind(hostedView: hostedView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority)
|
||||
hostedToWindowId[hostedId] = windowId
|
||||
pruneHostedMappings(for: windowId, validHostedIds: nextPortal.hostedIds())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ struct WorkspaceContentView: View {
|
|||
@ObservedObject var workspace: Workspace
|
||||
let isWorkspaceVisible: Bool
|
||||
let isWorkspaceInputActive: Bool
|
||||
let workspacePortalPriority: Int
|
||||
@State private var config = GhosttyConfig.load()
|
||||
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ struct WorkspaceContentView: View {
|
|||
isFocused: isFocused,
|
||||
isSelectedInPane: isSelectedInPane,
|
||||
isVisibleInUI: isVisibleInUI,
|
||||
portalPriority: workspacePortalPriority,
|
||||
isSplit: isSplit,
|
||||
appearance: appearance,
|
||||
notificationStore: notificationStore,
|
||||
|
|
|
|||
|
|
@ -1803,7 +1803,7 @@ final class WorkspaceMountPolicyTests: XCTestCase {
|
|||
XCTAssertEqual(next, [a])
|
||||
}
|
||||
|
||||
func testCycleHotModeWarmsSelectedAndImmediateNeighbors() {
|
||||
func testCycleHotModeKeepsOnlySelectedWhenNoPinnedHandoff() {
|
||||
let a = UUID()
|
||||
let b = UUID()
|
||||
let c = UUID()
|
||||
|
|
@ -1819,7 +1819,7 @@ final class WorkspaceMountPolicyTests: XCTestCase {
|
|||
maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
|
||||
)
|
||||
|
||||
XCTAssertEqual(next, [c, b, d])
|
||||
XCTAssertEqual(next, [c])
|
||||
}
|
||||
|
||||
func testCycleHotModeRespectsMaxMountedLimit() {
|
||||
|
|
@ -1837,7 +1837,7 @@ final class WorkspaceMountPolicyTests: XCTestCase {
|
|||
maxMounted: 2
|
||||
)
|
||||
|
||||
XCTAssertEqual(next, [b, a])
|
||||
XCTAssertEqual(next, [b])
|
||||
}
|
||||
|
||||
func testPinnedIdsAreRetainedAcrossReconcile() {
|
||||
|
|
@ -1857,6 +1857,23 @@ final class WorkspaceMountPolicyTests: XCTestCase {
|
|||
|
||||
XCTAssertEqual(next, [c, a])
|
||||
}
|
||||
|
||||
func testCycleHotModeKeepsRetiringWorkspaceWhenPinned() {
|
||||
let a = UUID()
|
||||
let b = UUID()
|
||||
let orderedTabIds: [UUID] = [a, b]
|
||||
|
||||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||
current: [a],
|
||||
selected: b,
|
||||
pinnedIds: [a],
|
||||
orderedTabIds: orderedTabIds,
|
||||
isCycleHot: true,
|
||||
maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
|
||||
)
|
||||
|
||||
XCTAssertEqual(next, [b, a])
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -1903,6 +1920,35 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
|
|||
|
||||
@MainActor
|
||||
final class TerminalWindowPortalLifecycleTests: XCTestCase {
|
||||
func testPortalHostInstallsAboveContentViewForVisibility() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
let portal = WindowTerminalPortal(window: window)
|
||||
_ = portal.viewAtWindowPoint(NSPoint(x: 1, y: 1))
|
||||
|
||||
guard let contentView = window.contentView,
|
||||
let container = contentView.superview else {
|
||||
XCTFail("Expected content container")
|
||||
return
|
||||
}
|
||||
|
||||
guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }),
|
||||
let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else {
|
||||
XCTFail("Expected host/content views in same container")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertGreaterThan(
|
||||
hostIndex,
|
||||
contentIndex,
|
||||
"Portal host must remain above content view so portal-hosted terminals stay visible"
|
||||
)
|
||||
}
|
||||
|
||||
func testRegistryPrunesPortalWhenWindowCloses() {
|
||||
let baseline = TerminalWindowPortalRegistry.debugPortalCount()
|
||||
let window = NSWindow(
|
||||
|
|
@ -1982,4 +2028,85 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase {
|
|||
"Portal hit-testing should resolve the terminal view for Finder file drops"
|
||||
)
|
||||
}
|
||||
|
||||
func testVisibilityTransitionBringsHostedViewToFront() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
let portal = WindowTerminalPortal(window: window)
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180))
|
||||
let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180))
|
||||
contentView.addSubview(anchor1)
|
||||
contentView.addSubview(anchor2)
|
||||
|
||||
let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
|
||||
let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1)
|
||||
let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
|
||||
let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2)
|
||||
|
||||
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true)
|
||||
portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true)
|
||||
|
||||
let overlapInContent = NSPoint(x: 120, y: 100)
|
||||
let overlapInWindow = contentView.convert(overlapInContent, to: nil)
|
||||
XCTAssertTrue(
|
||||
portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2,
|
||||
"Latest bind should be top-most before visibility transition"
|
||||
)
|
||||
|
||||
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: false)
|
||||
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true)
|
||||
XCTAssertTrue(
|
||||
portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1,
|
||||
"Becoming visible should refresh z-order for already-hosted view"
|
||||
)
|
||||
}
|
||||
|
||||
func testPriorityIncreaseBringsHostedViewToFrontWithoutVisibilityToggle() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
let portal = WindowTerminalPortal(window: window)
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180))
|
||||
let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180))
|
||||
contentView.addSubview(anchor1)
|
||||
contentView.addSubview(anchor2)
|
||||
|
||||
let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
|
||||
let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1)
|
||||
let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
|
||||
let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2)
|
||||
|
||||
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 1)
|
||||
portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true, zPriority: 2)
|
||||
|
||||
let overlapInContent = NSPoint(x: 120, y: 100)
|
||||
let overlapInWindow = contentView.convert(overlapInContent, to: nil)
|
||||
XCTAssertTrue(
|
||||
portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2,
|
||||
"Higher-priority terminal should initially be top-most"
|
||||
)
|
||||
|
||||
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 2)
|
||||
XCTAssertTrue(
|
||||
portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1,
|
||||
"Promoting z-priority should bring an already-visible terminal to front"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue