diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9b2f218e..fa539d3d 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3713,7 +3713,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent var refreshedCount = 0 forEachTerminalPanel { terminalPanel in terminalPanel.hostedView.reconcileGeometryNow() - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "appDelegate.refreshAfterGhosttyConfigReload") refreshedCount += 1 } #if DEBUG diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index cb12b170..235ef9b9 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1765,6 +1765,20 @@ final class WindowBrowserPortal: NSObject { ) } + private static func searchOverlayConfigurationsEquivalent( + _ lhs: BrowserPortalSearchOverlayConfiguration?, + _ rhs: BrowserPortalSearchOverlayConfiguration? + ) -> Bool { + switch (lhs, rhs) { + case (nil, nil): + return true + case let (lhs?, rhs?): + return lhs.panelId == rhs.panelId && lhs.searchState === rhs.searchState + default: + return false + } + } + /// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping. /// SwiftUI/AppKit hosting layers can briefly report an anchor bounds rect larger than the /// visible split pane during rearrangement; intersecting through ancestor bounds keeps the @@ -1953,6 +1967,7 @@ final class WindowBrowserPortal: NSObject { /// do not keep an old anchor visible. func updateEntryVisibility(forWebViewId webViewId: ObjectIdentifier, visibleInUI: Bool, zPriority: Int) { guard var entry = entriesByWebViewId[webViewId] else { return } + guard entry.visibleInUI != visibleInUI || entry.zPriority != zPriority else { return } entry.visibleInUI = visibleInUI entry.zPriority = zPriority entriesByWebViewId[webViewId] = entry @@ -1968,6 +1983,7 @@ final class WindowBrowserPortal: NSObject { func updateDropZoneOverlay(forWebViewId webViewId: ObjectIdentifier, zone: DropZone?) { guard var entry = entriesByWebViewId[webViewId] else { return } + guard entry.dropZone != zone else { return } entry.dropZone = zone entriesByWebViewId[webViewId] = entry entry.containerView?.setDropZoneOverlay(zone: zone) @@ -1975,6 +1991,7 @@ final class WindowBrowserPortal: NSObject { func updatePaneDropContext(forWebViewId webViewId: ObjectIdentifier, context: BrowserPaneDropContext?) { guard var entry = entriesByWebViewId[webViewId] else { return } + guard entry.paneDropContext != context else { return } entry.paneDropContext = context entriesByWebViewId[webViewId] = entry entry.containerView?.setPaneDropContext(context) @@ -1985,6 +2002,7 @@ final class WindowBrowserPortal: NSObject { configuration: BrowserPortalSearchOverlayConfiguration? ) { guard var entry = entriesByWebViewId[webViewId] else { return } + guard !Self.searchOverlayConfigurationsEquivalent(entry.searchOverlay, configuration) else { return } entry.searchOverlay = configuration entriesByWebViewId[webViewId] = entry entry.containerView?.setSearchOverlay(configuration) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 7855f1fc..278a9111 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2121,6 +2121,10 @@ final class TerminalSurface: Identifiable, ObservableObject { surfaceView.tabId = newTabId } + func isAttached(to view: GhosttyNSView) -> Bool { + attachedView === view && surface != nil + } + func portalBindingGeneration() -> UInt64 { portalLifecycleGeneration } @@ -2262,6 +2266,9 @@ final class TerminalSurface: Identifiable, ObservableObject { // removed/re-added (or briefly have window/screen nil) without recreating the surface. // Ghostty's vsync-driven renderer depends on having a valid display id; if it is missing // or stale, the surface can appear visually frozen until a focus/visibility change. + // SwiftUI also re-enters this path for ordinary state propagation (drag hover, active + // markers, visibility flags), so avoid forcing a geometry refresh when the attachment + // itself is unchanged. if attachedView === view && surface != nil { #if DEBUG dlog("surface.attach.reuse surface=\(id.uuidString.prefix(5)) view=\(Unmanaged.passUnretained(view).toOpaque())") @@ -2272,7 +2279,6 @@ final class TerminalSurface: Identifiable, ObservableObject { let s = surface { ghostty_surface_set_display_id(s, displayID) } - view.forceRefreshSurface() return } @@ -2570,6 +2576,7 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif } + @discardableResult func updateSize( width: CGFloat, height: CGFloat, @@ -2577,15 +2584,15 @@ final class TerminalSurface: Identifiable, ObservableObject { yScale: CGFloat, layerScale: CGFloat, backingSize: CGSize? = nil - ) { - guard let surface = surface else { return } + ) -> Bool { + guard let surface = surface else { return false } _ = layerScale let resolvedBackingWidth = backingSize?.width ?? (width * xScale) let resolvedBackingHeight = backingSize?.height ?? (height * yScale) let wpx = pixelDimension(from: resolvedBackingWidth) let hpx = pixelDimension(from: resolvedBackingHeight) - guard wpx > 0, hpx > 0 else { return } + guard wpx > 0, hpx > 0 else { return false } let scaleChanged = !scaleApproximatelyEqual(xScale, lastXScale) || !scaleApproximatelyEqual(yScale, lastYScale) let sizeChanged = wpx != lastPixelWidth || hpx != lastPixelHeight @@ -2594,7 +2601,7 @@ final class TerminalSurface: Identifiable, ObservableObject { Self.sizeLog("updateSize-call surface=\(id.uuidString.prefix(8)) size=\(wpx)x\(hpx) prev=\(lastPixelWidth)x\(lastPixelHeight) changed=\((scaleChanged || sizeChanged) ? 1 : 0)") #endif - guard scaleChanged || sizeChanged else { return } + guard scaleChanged || sizeChanged else { return false } #if DEBUG if sizeChanged { @@ -2616,10 +2623,11 @@ final class TerminalSurface: Identifiable, ObservableObject { } // Let Ghostty continue rendering on its own wakeups for steady-state frames. + return true } /// Force a full size recalculation and surface redraw. - func forceRefresh() { + func forceRefresh(reason: String = "unspecified") { let hasSurface = surface != nil let viewState: String if let view = attachedView { @@ -2632,7 +2640,7 @@ final class TerminalSurface: Identifiable, ObservableObject { } #if DEBUG let ts = ISO8601DateFormatter().string(from: Date()) - let line = "[\(ts)] forceRefresh: \(id) \(viewState)\n" + let line = "[\(ts)] forceRefresh: \(id) reason=\(reason) \(viewState)\n" let logPath = "/tmp/cmux-refresh-debug.log" if let handle = FileHandle(forWritingAtPath: logPath) { handle.seekToEndOfFile() @@ -2941,6 +2949,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var lastScrollEventTime: CFTimeInterval = 0 private var visibleInUI: Bool = true private var pendingSurfaceSize: CGSize? + private var lastDrawableSize: CGSize = .zero private var isFindEscapeSuppressionArmed = false #if DEBUG private var lastSizeSkipSignature: String? @@ -3114,14 +3123,22 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } func attachSurface(_ surface: TerminalSurface) { - appliedColorScheme = nil + let isSameSurface = terminalSurface === surface + let isAlreadyAttached = surface.isAttached(to: self) + if !isSameSurface { + appliedColorScheme = nil + } terminalSurface = surface tabId = surface.tabId - surface.attachToView(self) + if !isAlreadyAttached { + surface.attachToView(self) + } surface.setKeyboardCopyModeActive(keyboardCopyModeActive) - updateSurfaceSize() + if !isAlreadyAttached { + updateSurfaceSize() + } applySurfaceBackground() - applySurfaceColorScheme(force: true) + applySurfaceColorScheme(force: !isSameSurface || !isAlreadyAttached) } override func viewDidMoveToWindow() { @@ -3229,8 +3246,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return currentBounds } - private func updateSurfaceSize(size: CGSize? = nil) { - guard let terminalSurface = terminalSurface else { return } + @discardableResult + private func updateSurfaceSize(size: CGSize? = nil) -> Bool { + guard let terminalSurface = terminalSurface else { return false } let size = resolvedSurfaceSize(preferred: size) guard size.width > 0 && size.height > 0 else { #if DEBUG @@ -3244,7 +3262,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { lastSizeSkipSignature = signature } #endif - return + return false } pendingSurfaceSize = size guard let window else { @@ -3258,7 +3276,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { lastSizeSkipSignature = signature } #endif - return + return false } // First principles: derive pixel size from AppKit's backing conversion for the current @@ -3276,7 +3294,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { lastSizeSkipSignature = signature } #endif - return + return false } #if DEBUG if lastSizeSkipSignature != nil { @@ -3295,17 +3313,29 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { width: floor(max(0, backingSize.width)), height: floor(max(0, backingSize.height)) ) + var didChange = false CATransaction.begin() CATransaction.setDisableActions(true) + if let layer, !nearlyEqual(layer.contentsScale, layerScale) { + didChange = true + } layer?.contentsScale = layerScale layer?.masksToBounds = true if let metalLayer = layer as? CAMetalLayer { - metalLayer.drawableSize = drawablePixelSize + if drawablePixelSize != lastDrawableSize || metalLayer.drawableSize != drawablePixelSize { + if metalLayer.drawableSize != drawablePixelSize { + didChange = true + } + if metalLayer.drawableSize != drawablePixelSize { + metalLayer.drawableSize = drawablePixelSize + } + lastDrawableSize = drawablePixelSize + } } CATransaction.commit() - terminalSurface.updateSize( + let surfaceSizeChanged = terminalSurface.updateSize( width: size.width, height: size.height, xScale: xScale, @@ -3313,15 +3343,19 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { layerScale: layerScale, backingSize: backingSize ) + return didChange || surfaceSizeChanged } - fileprivate func pushTargetSurfaceSize(_ size: CGSize) { + @discardableResult + fileprivate func pushTargetSurfaceSize(_ size: CGSize) -> Bool { updateSurfaceSize(size: size) } - /// Force a full size recalculation and Metal layer refresh. - /// Resets cached metrics so updateSurfaceSize() re-runs unconditionally. - func forceRefreshSurface() { + /// Force a full size reconciliation for the current bounds. + /// Keep the drawable-size cache intact so redundant refresh paths do not + /// reallocate Metal drawables when the pixel size is unchanged. + @discardableResult + func forceRefreshSurface() -> Bool { updateSurfaceSize() } @@ -4882,6 +4916,7 @@ final class GhosttySurfaceScrollView: NSView { private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView private let keyboardCopyModeBadgeLabel: NSTextField private var searchOverlayHostingView: NSHostingView? + private var lastSearchOverlayStateID: ObjectIdentifier? private var observers: [NSObjectProtocol] = [] private var windowObservers: [NSObjectProtocol] = [] private var isLiveScrolling = false @@ -4908,6 +4943,9 @@ final class GhosttySurfaceScrollView: NSView { #if DEBUG private var lastDropZoneOverlayLogSignature: String? + private var dragLayoutLogSequence: UInt64 = 0 + private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") + private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder") private static var flashCounts: [UUID: Int] = [:] private static var drawCounts: [UUID: Int] = [:] private static var lastDrawTimes: [UUID: CFTimeInterval] = [:] @@ -5238,36 +5276,50 @@ final class GhosttySurfaceScrollView: NSView { /// Reconcile AppKit geometry with ghostty surface geometry synchronously. /// Used after split topology mutations (close/split) to prevent a stale one-frame /// IOSurface size from being presented after pane expansion. - func reconcileGeometryNow() { + @discardableResult + func reconcileGeometryNow() -> Bool { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.reconcileGeometryNow() } - return + return false } - synchronizeGeometryAndContent() + return synchronizeGeometryAndContent() } /// Request an immediate terminal redraw after geometry updates so stale IOSurface /// contents do not remain stretched during live resize churn. - func refreshSurfaceNow() { - surfaceView.terminalSurface?.forceRefresh() + func refreshSurfaceNow(reason: String = "portal.refreshSurfaceNow") { + surfaceView.terminalSurface?.forceRefresh(reason: reason) } - private func synchronizeGeometryAndContent() { + @discardableResult + private func synchronizeGeometryAndContent() -> Bool { CATransaction.begin() CATransaction.setDisableActions(true) defer { CATransaction.commit() } - backgroundView.frame = bounds - scrollView.frame = bounds + let previousSurfaceSize = surfaceView.frame.size + _ = setFrameIfNeeded(backgroundView, to: bounds) + _ = setFrameIfNeeded(scrollView, to: bounds) let targetSize = scrollView.bounds.size - surfaceView.frame.size = targetSize - documentView.frame.size.width = scrollView.bounds.width - inactiveOverlayView.frame = bounds +#if DEBUG + logLayoutDuringActiveDrag(targetSize: targetSize) +#endif + let targetSurfaceFrame = CGRect(origin: surfaceView.frame.origin, size: targetSize) + _ = setFrameIfNeeded(surfaceView, to: targetSurfaceFrame) + let targetDocumentFrame = CGRect( + origin: documentView.frame.origin, + size: CGSize(width: scrollView.bounds.width, height: documentView.frame.height) + ) + _ = setFrameIfNeeded(documentView, to: targetDocumentFrame) + _ = setFrameIfNeeded(inactiveOverlayView, to: bounds) if let zone = activeDropZone { - dropZoneOverlayView.frame = dropZoneOverlayFrame(for: zone, in: bounds.size) + _ = setFrameIfNeeded( + dropZoneOverlayView, + to: dropZoneOverlayFrame(for: zone, in: bounds.size) + ) } if let pending = pendingDropZone, bounds.width > 2, @@ -5281,15 +5333,68 @@ final class GhosttySurfaceScrollView: NSView { // same initial animation as direct drop-zone activation. setDropZoneOverlay(zone: pending) } - notificationRingOverlayView.frame = bounds - flashOverlayView.frame = bounds + _ = setFrameIfNeeded(notificationRingOverlayView, to: bounds) + _ = setFrameIfNeeded(flashOverlayView, to: bounds) updateNotificationRingPath() updateFlashPath() synchronizeScrollView() synchronizeSurfaceView() - synchronizeCoreSurface() + let didCoreSurfaceChange = synchronizeCoreSurface() + return !sizeApproximatelyEqual(previousSurfaceSize, targetSize) || didCoreSurfaceChange } + @discardableResult + private func setFrameIfNeeded(_ view: NSView, to frame: CGRect) -> Bool { + guard !Self.rectApproximatelyEqual(view.frame, frame) else { return false } + view.frame = frame + return true + } + + private func sizeApproximatelyEqual(_ lhs: CGSize, _ rhs: CGSize, epsilon: CGFloat = 0.0001) -> Bool { + abs(lhs.width - rhs.width) <= epsilon && abs(lhs.height - rhs.height) <= epsilon + } + + private func pointApproximatelyEqual(_ lhs: CGPoint, _ rhs: CGPoint, epsilon: CGFloat = 0.5) -> Bool { + abs(lhs.x - rhs.x) <= epsilon && abs(lhs.y - rhs.y) <= epsilon + } + +#if DEBUG + private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { + switch eventType { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + return true + default: + return false + } + } + + private func logLayoutDuringActiveDrag(targetSize: CGSize) { + let pasteboardTypes = NSPasteboard(name: .drag).types + let hasTabDrag = pasteboardTypes?.contains(Self.tabTransferPasteboardType) == true + let hasSidebarDrag = pasteboardTypes?.contains(Self.sidebarTabReorderPasteboardType) == true + let eventType = NSApp.currentEvent?.type + let hasActiveDrag = + activeDropZone != nil || + pendingDropZone != nil || + ((hasTabDrag || hasSidebarDrag) && Self.isDragMouseEvent(eventType)) + guard hasActiveDrag else { return } + + dragLayoutLogSequence &+= 1 + let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" + let activeZone = activeDropZone.map { String(describing: $0) } ?? "none" + let pendingZone = pendingDropZone.map { String(describing: $0) } ?? "none" + let event = eventType.map { String(describing: $0) } ?? "nil" + dlog( + "terminal.layout.drag surface=\(surface) seq=\(dragLayoutLogSequence) " + + "activeZone=\(activeZone) pendingZone=\(pendingZone) " + + "hasTabDrag=\(hasTabDrag ? 1 : 0) hasSidebarDrag=\(hasSidebarDrag ? 1 : 0) " + + "event=\(event) inWindow=\(window != nil ? 1 : 0) " + + "bounds=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + + "target=\(String(format: "%.1fx%.1f", targetSize.width, targetSize.height))" + ) + } +#endif + override func viewDidMoveToWindow() { super.viewDidMoveToWindow() windowObservers.forEach { NotificationCenter.default.removeObserver($0) } @@ -5385,10 +5490,15 @@ final class GhosttySurfaceScrollView: NSView { return } + let targetHidden = !visible + let targetOpacity: Float = visible ? 1 : 0 + guard notificationRingOverlayView.isHidden != targetHidden || + notificationRingLayer.opacity != targetOpacity else { return } + CATransaction.begin() CATransaction.setDisableActions(true) - notificationRingOverlayView.isHidden = !visible - notificationRingLayer.opacity = visible ? 1 : 0 + notificationRingOverlayView.isHidden = targetHidden + notificationRingLayer.opacity = targetOpacity CATransaction.commit() } @@ -5405,6 +5515,8 @@ final class GhosttySurfaceScrollView: NSView { guard let terminalSurface = surfaceView.terminalSurface, let searchState else { let hadOverlay = searchOverlayHostingView != nil + lastSearchOverlayStateID = nil + guard hadOverlay else { return } #if DEBUG dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)") #endif @@ -5414,6 +5526,16 @@ final class GhosttySurfaceScrollView: NSView { return } + let searchStateID = ObjectIdentifier(searchState) + if let overlay = searchOverlayHostingView, + lastSearchOverlayStateID == searchStateID, + overlay.superview === self { + if !keyboardCopyModeBadgeView.isHidden { + addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay) + } + return + } + let hadOverlay = searchOverlayHostingView != nil #if DEBUG dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")") @@ -5457,6 +5579,7 @@ final class GhosttySurfaceScrollView: NSView { if !keyboardCopyModeBadgeView.isHidden { addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay) } + lastSearchOverlayStateID = searchStateID return } @@ -5474,6 +5597,7 @@ final class GhosttySurfaceScrollView: NSView { addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay) } searchOverlayHostingView = overlay + lastSearchOverlayStateID = searchStateID } func setKeyboardCopyModeIndicator(visible: Bool) { @@ -6356,16 +6480,18 @@ final class GhosttySurfaceScrollView: NSView { private func synchronizeSurfaceView() { let visibleRect = scrollView.contentView.documentVisibleRect + guard !pointApproximatelyEqual(surfaceView.frame.origin, visibleRect.origin) else { return } surfaceView.frame.origin = visibleRect.origin } /// Match upstream Ghostty behavior: use content area width (excluding non-content /// regions such as scrollbar space) when telling libghostty the terminal size. - private func synchronizeCoreSurface() { + @discardableResult + private func synchronizeCoreSurface() -> Bool { let width = max(0, scrollView.contentSize.width - overlayScrollbarInsetWidth()) let height = surfaceView.frame.height - guard width > 0, height > 0 else { return } - surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height)) + guard width > 0, height > 0 else { return false } + return surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height)) } /// Reserve overlay scrollbar gutter so wrapped text never sits underneath a visible scroller. @@ -6425,19 +6551,30 @@ final class GhosttySurfaceScrollView: NSView { } private func synchronizeScrollView() { - documentView.frame.size.height = documentHeight() + var didChangeGeometry = false + let targetDocumentHeight = documentHeight() + if abs(documentView.frame.height - targetDocumentHeight) > 0.5 { + documentView.frame.size.height = targetDocumentHeight + didChangeGeometry = true + } if !isLiveScrolling { let cellHeight = surfaceView.cellSize.height if cellHeight > 0, let scrollbar = surfaceView.scrollbar { let offsetY = CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight - scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY)) + let targetOrigin = CGPoint(x: 0, y: offsetY) + if !pointApproximatelyEqual(scrollView.contentView.bounds.origin, targetOrigin) { + scrollView.contentView.scroll(to: targetOrigin) + didChangeGeometry = true + } lastSentRow = Int(scrollbar.offset) } } - scrollView.reflectScrolledClipView(scrollView.contentView) + if didChangeGeometry { + scrollView.reflectScrolledClipView(scrollView.contentView) + } } private func handleScrollChange() { @@ -6669,31 +6806,57 @@ struct GhosttyTerminalView: NSViewRepresentable { private final class HostContainerView: NSView { var onDidMoveToWindow: (() -> Void)? var onGeometryChanged: (() -> Void)? + private(set) var geometryRevision: UInt64 = 0 + private var lastReportedGeometryState: GeometryState? + + private struct GeometryState: Equatable { + let frame: CGRect + let bounds: CGRect + let windowNumber: Int? + let superviewID: ObjectIdentifier? + } + + private func currentGeometryState() -> GeometryState { + GeometryState( + frame: frame, + bounds: bounds, + windowNumber: window?.windowNumber, + superviewID: superview.map(ObjectIdentifier.init) + ) + } + + private func notifyGeometryChangedIfNeeded() { + let state = currentGeometryState() + guard state != lastReportedGeometryState else { return } + lastReportedGeometryState = state + geometryRevision &+= 1 + onGeometryChanged?() + } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() onDidMoveToWindow?() - onGeometryChanged?() + notifyGeometryChangedIfNeeded() } override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() - onGeometryChanged?() + notifyGeometryChangedIfNeeded() } override func layout() { super.layout() - onGeometryChanged?() + notifyGeometryChangedIfNeeded() } override func setFrameOrigin(_ newOrigin: NSPoint) { super.setFrameOrigin(newOrigin) - onGeometryChanged?() + notifyGeometryChangedIfNeeded() } override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) - onGeometryChanged?() + notifyGeometryChangedIfNeeded() } } @@ -6706,6 +6869,7 @@ struct GhosttyTerminalView: NSViewRepresentable { var desiredPortalZPriority: Int = 0 var lastBoundHostId: ObjectIdentifier? var lastPaneDropZone: DropZone? + var lastSynchronizedHostGeometryRevision: UInt64 = 0 weak var hostedView: GhosttySurfaceScrollView? } @@ -6825,6 +6989,7 @@ struct GhosttyTerminalView: NSViewRepresentable { expectedGeneration: portalExpectedGeneration ) coordinator.lastBoundHostId = ObjectIdentifier(host) + coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) hostedView.setActive(coordinator.desiredIsActive) hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) @@ -6856,10 +7021,12 @@ struct GhosttyTerminalView: NSViewRepresentable { hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) } TerminalWindowPortalRegistry.synchronizeForAnchor(host) + coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision } if host.window != nil { let hostId = ObjectIdentifier(host) + let geometryRevision = host.geometryRevision let shouldBindNow = coordinator.lastBoundHostId != hostId || hostedView.superview == nil || @@ -6876,8 +7043,11 @@ struct GhosttyTerminalView: NSViewRepresentable { expectedGeneration: portalExpectedGeneration ) coordinator.lastBoundHostId = hostId + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + } else if coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { + TerminalWindowPortalRegistry.synchronizeForAnchor(host) + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision } - TerminalWindowPortalRegistry.synchronizeForAnchor(host) } else { // Bind is deferred until host moves into a window. Update the // existing portal entry's visibleInUI now so that any portal sync diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 198d42c0..cbb7aaff 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -3091,17 +3091,27 @@ struct WebViewRepresentable: NSViewRepresentable { var desiredPortalVisibleInUI: Bool = true var desiredPortalZPriority: Int = 0 var lastPortalHostId: ObjectIdentifier? + var lastSynchronizedHostGeometryRevision: UInt64 = 0 } final class HostContainerView: NSView { var onDidMoveToWindow: (() -> Void)? var onGeometryChanged: (() -> Void)? + private(set) var geometryRevision: UInt64 = 0 + private var lastReportedGeometryState: GeometryState? private struct HostedInspectorDividerHit { let containerView: NSView let pageView: NSView let inspectorView: NSView } + private struct GeometryState: Equatable { + let frame: CGRect + let bounds: CGRect + let windowNumber: Int? + let superviewID: ObjectIdentifier? + } + private struct HostedInspectorDividerDragState { let containerView: NSView let pageView: NSView @@ -3225,6 +3235,23 @@ struct WebViewRepresentable: NSViewRepresentable { abs(lhs.height - rhs.height) <= epsilon } + private func currentGeometryState() -> GeometryState { + GeometryState( + frame: frame, + bounds: bounds, + windowNumber: window?.windowNumber, + superviewID: superview.map(ObjectIdentifier.init) + ) + } + + private func notifyGeometryChangedIfNeeded() { + let state = currentGeometryState() + guard state != lastReportedGeometryState else { return } + lastReportedGeometryState = state + geometryRevision &+= 1 + onGeometryChanged?() + } + override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if window == nil { @@ -3234,7 +3261,7 @@ struct WebViewRepresentable: NSViewRepresentable { } window?.invalidateCursorRects(for: self) onDidMoveToWindow?() - onGeometryChanged?() + notifyGeometryChangedIfNeeded() #if DEBUG debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToWindow") #endif @@ -3243,7 +3270,7 @@ struct WebViewRepresentable: NSViewRepresentable { override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() reapplyHostedInspectorDividerIfNeeded(reason: "viewDidMoveToSuperview") - onGeometryChanged?() + notifyGeometryChangedIfNeeded() #if DEBUG debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToSuperview") #endif @@ -3252,7 +3279,7 @@ struct WebViewRepresentable: NSViewRepresentable { override func layout() { super.layout() reapplyHostedInspectorDividerIfNeeded(reason: "layout") - onGeometryChanged?() + notifyGeometryChangedIfNeeded() #if DEBUG debugLogHostedInspectorLayoutIfNeeded(reason: "layout") #endif @@ -3262,7 +3289,7 @@ struct WebViewRepresentable: NSViewRepresentable { super.setFrameOrigin(newOrigin) window?.invalidateCursorRects(for: self) reapplyHostedInspectorDividerIfNeeded(reason: "setFrameOrigin") - onGeometryChanged?() + notifyGeometryChangedIfNeeded() #if DEBUG debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameOrigin") #endif @@ -3272,7 +3299,7 @@ struct WebViewRepresentable: NSViewRepresentable { super.setFrameSize(newSize) window?.invalidateCursorRects(for: self) reapplyHostedInspectorDividerIfNeeded(reason: "setFrameSize") - onGeometryChanged?() + notifyGeometryChangedIfNeeded() #if DEBUG debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameSize") #endif @@ -3848,6 +3875,7 @@ struct WebViewRepresentable: NSViewRepresentable { BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext) BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) coordinator.lastPortalHostId = ObjectIdentifier(host) + coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision } host.onGeometryChanged = { [weak host, weak coordinator, weak portalAnchorView] in guard let host, let coordinator, let portalAnchorView else { return } @@ -3855,6 +3883,7 @@ struct WebViewRepresentable: NSViewRepresentable { guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return } Self.installPortalAnchorView(portalAnchorView, in: host) BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) + coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision } if !shouldAttachWebView { @@ -3865,6 +3894,7 @@ struct WebViewRepresentable: NSViewRepresentable { if host.window != nil { let hostId = ObjectIdentifier(host) + let geometryRevision = host.geometryRevision let shouldBindNow = coordinator.lastPortalHostId != hostId || webView.superview == nil || @@ -3879,13 +3909,18 @@ struct WebViewRepresentable: NSViewRepresentable { zPriority: coordinator.desiredPortalZPriority ) coordinator.lastPortalHostId = hostId + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision } BrowserWindowPortalRegistry.updatePaneTopChromeHeight( for: webView, height: shouldAttachWebView ? paneTopChromeHeight : 0 ) BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) - BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) + if !shouldBindNow, + coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { + BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + } } else { // Bind is deferred until host moves into a window. Keep the current // portal entry's desired state in sync so stale callbacks cannot keep @@ -3930,6 +3965,7 @@ struct WebViewRepresentable: NSViewRepresentable { if let previousWebView = coordinator.webView, previousWebView !== webView { BrowserWindowPortalRegistry.detach(webView: previousWebView) coordinator.lastPortalHostId = nil + coordinator.lastSynchronizedHostGeometryRevision = 0 } coordinator.panel = panel coordinator.webView = webView @@ -4032,6 +4068,7 @@ struct WebViewRepresentable: NSViewRepresentable { BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: nil) BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: nil) coordinator.lastPortalHostId = nil + coordinator.lastSynchronizedHostGeometryRevision = 0 } private func currentPaneDropContext() -> BrowserPaneDropContext? { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 2c2efbfc..0920d588 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2880,7 +2880,7 @@ class TabManager: ObservableObject { continue } terminal.hostedView.reconcileGeometryNow() - terminal.surface.forceRefresh() + terminal.surface.forceRefresh(reason: "tabManager.reconcileVisibleTerminalGeometry") } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index f2b72af4..7da9f856 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -4161,7 +4161,7 @@ class TerminalController { var refreshedCount = 0 for panel in ws.panels.values { if let terminalPanel = panel as? TerminalPanel { - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceRefresh") refreshedCount += 1 } } @@ -4243,7 +4243,7 @@ class TerminalController { // Ensure we present a new frame after injecting input so snapshot-based tests (and // socket-driven agents) can observe the updated terminal without requiring a focus // change to trigger a draw. - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendText") queued = false } else { // Avoid blocking the main actor waiting for view/surface attachment. @@ -4301,7 +4301,7 @@ class TerminalController { result = .err(code: "invalid_params", message: "Unknown key", data: ["key": key]) return } - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendKey") result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } return result @@ -4333,7 +4333,7 @@ class TerminalController { return } - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceClearHistory") let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok([ "workspace_id": ws.id.uuidString, @@ -11146,7 +11146,7 @@ class TerminalController { var cgImage = view.debugCopyIOSurfaceCGImage() if cgImage == nil { // If the surface is mid-attach we may not have contents yet. Nudge a draw and retry once. - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.debugCopyIOSurfaceRetry") cgImage = view.debugCopyIOSurfaceCGImage() } guard let cgImage else { @@ -13712,7 +13712,7 @@ class TerminalController { // (resets cached metrics so the Metal layer drawable resizes correctly) for panel in tab.panels.values { if let terminalPanel = panel as? TerminalPanel { - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.refreshAllTerminalPanels") refreshedCount += 1 } } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 56a1783f..c6758791 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -698,12 +698,13 @@ final class WindowTerminalPortal: NSObject { synchronizeAllHostedViews(excluding: nil) // During live resize, AppKit can deliver frame churn where host/container geometry - // settles a tick before the terminal's own scroll/surface hierarchy. Force a final - // in-place geometry + surface refresh for all visible entries in this window. + // settles a tick before the terminal's own scroll/surface hierarchy. Only force an + // in-place surface refresh when reconciliation actually changed terminal geometry. for entry in entriesByHostedId.values { guard let hostedView = entry.hostedView, !hostedView.isHidden else { continue } - hostedView.reconcileGeometryNow() - hostedView.refreshSurfaceNow() + if hostedView.reconcileGeometryNow() { + hostedView.refreshSurfaceNow(reason: "portal.externalGeometrySync") + } } } @@ -1392,7 +1393,7 @@ final class WindowTerminalPortal: NSObject { hostedView.frame = targetFrame CATransaction.commit() hostedView.reconcileGeometryNow() - hostedView.refreshSurfaceNow() + hostedView.refreshSurfaceNow(reason: "portal.frameChange") } if hasFiniteFrame { @@ -1431,7 +1432,7 @@ final class WindowTerminalPortal: NSObject { // normal frame-change refresh path won't run. Nudge geometry + redraw so newly // revealed terminals don't sit on a stale/blank IOSurface until later focus churn. hostedView.reconcileGeometryNow() - hostedView.refreshSurfaceNow() + hostedView.refreshSurfaceNow(reason: "portal.reveal") } if transientRecoveryReason == nil { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 58d88702..7a4bb58a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3432,11 +3432,11 @@ final class Workspace: Identifiable, ObservableObject { needsFollowUpPass = true } - hostedView.reconcileGeometryNow() + let geometryChanged = hostedView.reconcileGeometryNow() // Re-check surface after reconcileGeometryNow() which can trigger AppKit // layout and view lifecycle changes that free surfaces (#432). - if terminalPanel.surface.surface != nil { - terminalPanel.surface.forceRefresh() + if geometryChanged, terminalPanel.surface.surface != nil { + terminalPanel.surface.forceRefresh(reason: "workspace.geometryReconcile") } if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds { terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() @@ -3492,9 +3492,9 @@ final class Workspace: Identifiable, ObservableObject { let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in DispatchQueue.main.asyncAfter(deadline: .now() + delay) { guard let self, let panel = self.terminalPanel(for: panelId) else { return } - panel.hostedView.reconcileGeometryNow() - if panel.surface.surface != nil { - panel.surface.forceRefresh() + let geometryChanged = panel.hostedView.reconcileGeometryNow() + if geometryChanged, panel.surface.surface != nil { + panel.surface.forceRefresh(reason: "workspace.movedTerminalRefresh") } if panel.surface.surface == nil { panel.surface.requestBackgroundSurfaceStartIfNeeded()