diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 8da7833c..2e82cb66 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -326,6 +326,8 @@ final class WindowBrowserPortal: NSObject { private weak var installedContainerView: NSView? private weak var installedReferenceView: NSView? private var hasDeferredFullSyncScheduled = false + private var hasExternalGeometrySyncScheduled = false + private var geometryObservers: [NSObjectProtocol] = [] private struct Entry { weak var webView: WKWebView? @@ -345,9 +347,73 @@ final class WindowBrowserPortal: NSObject { hostView.layer?.masksToBounds = true hostView.translatesAutoresizingMaskIntoConstraints = true hostView.autoresizingMask = [] + installGeometryObservers(for: window) _ = ensureInstalled() } + private func installGeometryObservers(for window: NSWindow) { + guard geometryObservers.isEmpty else { return } + + let center = NotificationCenter.default + geometryObservers.append(center.addObserver( + forName: NSWindow.didResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSWindow.didEndLiveResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSSplitView.didResizeSubviewsNotification, + object: nil, + queue: .main + ) { [weak self] notification in + MainActor.assumeIsolated { + guard let self, + let splitView = notification.object as? NSSplitView, + let window = self.window, + splitView.window === window else { return } + self.scheduleExternalGeometrySynchronize() + } + }) + } + + private func removeGeometryObservers() { + for observer in geometryObservers { + NotificationCenter.default.removeObserver(observer) + } + geometryObservers.removeAll() + } + + private func scheduleExternalGeometrySynchronize() { + guard !hasExternalGeometrySyncScheduled else { return } + hasExternalGeometrySyncScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.hasExternalGeometrySyncScheduled = false + self.synchronizeAllEntriesFromExternalGeometryChange() + } + } + + private func synchronizeAllEntriesFromExternalGeometryChange() { + guard ensureInstalled() else { return } + installedContainerView?.layoutSubtreeIfNeeded() + installedReferenceView?.layoutSubtreeIfNeeded() + hostView.superview?.layoutSubtreeIfNeeded() + hostView.layoutSubtreeIfNeeded() + synchronizeAllWebViews(excluding: nil, source: "externalGeometry") + } + @discardableResult private func ensureInstalled() -> Bool { guard let window else { return false } @@ -419,13 +485,32 @@ final class WindowBrowserPortal: NSObject { return false } - private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { + private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> 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 } + private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect { + guard rect.origin.x.isFinite, + rect.origin.y.isFinite, + rect.size.width.isFinite, + rect.size.height.isFinite else { + return rect + } + let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0) + func snap(_ value: CGFloat) -> CGFloat { + (value * scale).rounded(.toNearestOrAwayFromZero) / scale + } + return NSRect( + x: snap(rect.origin.x), + y: snap(rect.origin.y), + width: max(0, snap(rect.size.width)), + height: max(0, snap(rect.size.height)) + ) + } + private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool { frame.minX < bounds.minX - epsilon || frame.minY < bounds.minY - epsilon || @@ -765,7 +850,8 @@ final class WindowBrowserPortal: NSObject { _ = synchronizeHostFrameToReference() let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) - let frameInHost = hostView.convert(frameInWindow, from: nil) + let frameInHostRaw = hostView.convert(frameInWindow, from: nil) + let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) let hostBounds = hostView.bounds let hasFiniteHostBounds = hostBounds.origin.x.isFinite && @@ -838,6 +924,8 @@ final class WindowBrowserPortal: NSObject { CATransaction.setDisableActions(true) containerView.frame = targetFrame CATransaction.commit() + webView.needsLayout = true + webView.layoutSubtreeIfNeeded() } let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size) @@ -952,6 +1040,7 @@ final class WindowBrowserPortal: NSObject { } func tearDown() { + removeGeometryObservers() for webViewId in Array(entriesByWebViewId.keys) { detachWebView(withId: webViewId) } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0e01b157..f42d79ae 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1501,6 +1501,17 @@ final class TerminalSurface: Identifiable, ObservableObject { } #endif + /// Match upstream Ghostty AppKit sizing: framebuffer dimensions are derived + /// from backing-space points and truncated (never rounded up). + private func pixelDimension(from value: CGFloat) -> UInt32 { + guard value.isFinite else { return 0 } + let floored = floor(max(0, value)) + if floored >= CGFloat(UInt32.max) { + return UInt32.max + } + return UInt32(floored) + } + private func scaleFactors(for view: GhosttyNSView) -> (x: CGFloat, y: CGFloat, layer: CGFloat) { let scale = max( 1.0, @@ -1784,8 +1795,9 @@ final class TerminalSurface: Identifiable, ObservableObject { } ghostty_surface_set_content_scale(createdSurface, scaleFactors.x, scaleFactors.y) - let wpx = UInt32((view.bounds.width * scaleFactors.x).rounded(.toNearestOrAwayFromZero)) - let hpx = UInt32((view.bounds.height * scaleFactors.y).rounded(.toNearestOrAwayFromZero)) + let backingSize = view.convertToBacking(NSRect(origin: .zero, size: view.bounds.size)).size + let wpx = pixelDimension(from: backingSize.width) + let hpx = pixelDimension(from: backingSize.height) if wpx > 0, hpx > 0 { ghostty_surface_set_size(createdSurface, wpx, hpx) lastPixelWidth = wpx @@ -1824,12 +1836,21 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif } - func updateSize(width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat) { + func updateSize( + width: CGFloat, + height: CGFloat, + xScale: CGFloat, + yScale: CGFloat, + layerScale: CGFloat, + backingSize: CGSize? = nil + ) { guard let surface = surface else { return } _ = layerScale - let wpx = UInt32((width * xScale).rounded(.toNearestOrAwayFromZero)) - let hpx = UInt32((height * yScale).rounded(.toNearestOrAwayFromZero)) + 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 } let scaleChanged = !scaleApproximatelyEqual(xScale, lastXScale) || !scaleApproximatelyEqual(yScale, lastYScale) @@ -2114,6 +2135,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private func setup() { // Only enable our instrumented CAMetalLayer in targeted debug/test scenarios. // The lock in GhosttyMetalLayer.nextDrawable() adds overhead we don't want in normal runs. + wantsLayer = true + layer?.masksToBounds = true installEventMonitor() updateTrackingAreas() registerForDraggedTypes(Array(Self.dropTypes)) @@ -2241,17 +2264,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ghostty_surface_set_display_id(surface, displayID) } - // Recompute from current bounds after layout, not stale pending sizes. + // Recompute from current bounds after layout. Pending size is only a fallback + // when we don't have usable bounds (e.g. detached/off-window transitions). superview?.layoutSubtreeIfNeeded() layoutSubtreeIfNeeded() - let targetSize: CGSize = { - let current = bounds.size - if current.width > 0, current.height > 0 { - return current - } - return pendingSurfaceSize ?? current - }() - updateSurfaceSize(size: targetSize) + updateSurfaceSize() applySurfaceBackground() applySurfaceColorScheme(force: true) applyWindowBackgroundIfActive() @@ -2291,9 +2308,30 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override var isOpaque: Bool { false } + private func resolvedSurfaceSize(preferred size: CGSize?) -> CGSize { + if let size, + size.width > 0, + size.height > 0 { + return size + } + + let currentBounds = bounds.size + if currentBounds.width > 0, currentBounds.height > 0 { + return currentBounds + } + + if let pending = pendingSurfaceSize, + pending.width > 0, + pending.height > 0 { + return pending + } + + return currentBounds + } + private func updateSurfaceSize(size: CGSize? = nil) { guard let terminalSurface = terminalSurface else { return } - let size = size ?? bounds.size + let size = resolvedSurfaceSize(preferred: size) guard size.width > 0 && size.height > 0 else { #if DEBUG let signature = "nonPositive-\(Int(size.width))x\(Int(size.height))" @@ -2353,12 +2391,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let xScale = backingSize.width / size.width let yScale = backingSize.height / size.height let layerScale = max(1.0, window.backingScaleFactor) + let drawablePixelSize = CGSize( + width: floor(max(0, backingSize.width)), + height: floor(max(0, backingSize.height)) + ) CATransaction.begin() CATransaction.setDisableActions(true) layer?.contentsScale = layerScale + layer?.masksToBounds = true if let metalLayer = layer as? CAMetalLayer { - metalLayer.drawableSize = backingSize + metalLayer.drawableSize = drawablePixelSize } CATransaction.commit() @@ -2367,9 +2410,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { height: size.height, xScale: xScale, yScale: yScale, - layerScale: layerScale + layerScale: layerScale, + backingSize: backingSize ) - pendingSurfaceSize = nil } fileprivate func pushTargetSurfaceSize(_ size: CGSize) { @@ -3559,6 +3602,8 @@ final class GhosttySurfaceScrollView: NSView { documentView.addSubview(surfaceView) super.init(frame: .zero) + wantsLayer = true + layer?.masksToBounds = true backgroundView.wantsLayer = true backgroundView.layer?.backgroundColor = @@ -3696,6 +3741,12 @@ final class GhosttySurfaceScrollView: NSView { 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() + } + private func synchronizeGeometryAndContent() { CATransaction.begin() CATransaction.setDisableActions(true) @@ -3705,7 +3756,6 @@ final class GhosttySurfaceScrollView: NSView { scrollView.frame = bounds let targetSize = scrollView.bounds.size surfaceView.frame.size = targetSize - surfaceView.pushTargetSurfaceSize(targetSize) documentView.frame.size.width = scrollView.bounds.width inactiveOverlayView.frame = bounds if let zone = activeDropZone { @@ -3729,6 +3779,7 @@ final class GhosttySurfaceScrollView: NSView { updateFlashPath() synchronizeScrollView() synchronizeSurfaceView() + synchronizeCoreSurface() } override func viewDidMoveToWindow() { @@ -4606,6 +4657,15 @@ final class GhosttySurfaceScrollView: NSView { 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() { + let width = scrollView.contentSize.width + let height = surfaceView.frame.height + guard width > 0, height > 0 else { return } + surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height)) + } + private func updateNotificationRingPath() { updateOverlayRingPath( layer: notificationRingLayer, diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 07831c71..cec6847e 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -536,6 +536,8 @@ final class WindowTerminalPortal: NSObject { private weak var installedReferenceView: NSView? private var installConstraints: [NSLayoutConstraint] = [] private var hasDeferredFullSyncScheduled = false + private var hasExternalGeometrySyncScheduled = false + private var geometryObservers: [NSObjectProtocol] = [] private struct Entry { weak var hostedView: GhosttySurfaceScrollView? @@ -550,13 +552,141 @@ final class WindowTerminalPortal: NSObject { init(window: NSWindow) { self.window = window super.init() - hostView.wantsLayer = false + hostView.wantsLayer = true + hostView.layer?.masksToBounds = true + hostView.postsFrameChangedNotifications = true + hostView.postsBoundsChangedNotifications = true hostView.translatesAutoresizingMaskIntoConstraints = false dividerOverlayView.translatesAutoresizingMaskIntoConstraints = true dividerOverlayView.autoresizingMask = [.width, .height] + installGeometryObservers(for: window) _ = ensureInstalled() } + private func installGeometryObservers(for window: NSWindow) { + guard geometryObservers.isEmpty else { return } + + let center = NotificationCenter.default + geometryObservers.append(center.addObserver( + forName: NSWindow.didResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSWindow.didEndLiveResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSSplitView.didResizeSubviewsNotification, + object: nil, + queue: .main + ) { [weak self] notification in + MainActor.assumeIsolated { + guard let self, + let splitView = notification.object as? NSSplitView, + let window = self.window, + splitView.window === window else { return } + self.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSView.frameDidChangeNotification, + object: hostView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSView.boundsDidChangeNotification, + object: hostView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + } + + private func removeGeometryObservers() { + for observer in geometryObservers { + NotificationCenter.default.removeObserver(observer) + } + geometryObservers.removeAll() + } + + private func scheduleExternalGeometrySynchronize() { + guard !hasExternalGeometrySyncScheduled else { return } + hasExternalGeometrySyncScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.hasExternalGeometrySyncScheduled = false + self.synchronizeAllEntriesFromExternalGeometryChange() + } + } + + private func synchronizeLayoutHierarchy() { + installedContainerView?.layoutSubtreeIfNeeded() + installedReferenceView?.layoutSubtreeIfNeeded() + hostView.superview?.layoutSubtreeIfNeeded() + hostView.layoutSubtreeIfNeeded() + _ = synchronizeHostFrameToReference() + } + + @discardableResult + private func synchronizeHostFrameToReference() -> Bool { + guard let container = installedContainerView, + let reference = installedReferenceView else { + return false + } + let frameInContainer = container.convert(reference.bounds, from: reference) + let hasFiniteFrame = + frameInContainer.origin.x.isFinite && + frameInContainer.origin.y.isFinite && + frameInContainer.size.width.isFinite && + frameInContainer.size.height.isFinite + guard hasFiniteFrame else { return false } + + if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostView.frame = frameInContainer + CATransaction.commit() +#if DEBUG + dlog( + "portal.hostFrame.update host=\(portalDebugToken(hostView)) " + + "frame=\(portalDebugFrame(frameInContainer))" + ) +#endif + } + return frameInContainer.width > 1 && frameInContainer.height > 1 + } + + private func synchronizeAllEntriesFromExternalGeometryChange() { + guard ensureInstalled() else { return } + synchronizeLayoutHierarchy() + 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. + for entry in entriesByHostedId.values { + guard let hostedView = entry.hostedView, !hostedView.isHidden else { continue } + hostedView.reconcileGeometryNow() + hostedView.refreshSurfaceNow() + } + } + private func ensureDividerOverlayOnTop() { if dividerOverlayView.superview !== hostView { dividerOverlayView.frame = hostView.bounds @@ -605,6 +735,8 @@ final class WindowTerminalPortal: NSObject { container.addSubview(overlay, positioned: .above, relativeTo: hostView) } + synchronizeLayoutHierarchy() + _ = synchronizeHostFrameToReference() ensureDividerOverlayOnTop() return true @@ -634,13 +766,32 @@ final class WindowTerminalPortal: NSObject { return false } - private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { + private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> 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 } + private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect { + guard rect.origin.x.isFinite, + rect.origin.y.isFinite, + rect.size.width.isFinite, + rect.size.height.isFinite else { + return rect + } + let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0) + func snap(_ value: CGFloat) -> CGFloat { + (value * scale).rounded(.toNearestOrAwayFromZero) / scale + } + return NSRect( + x: snap(rect.origin.x), + y: snap(rect.origin.y), + width: max(0, snap(rect.size.width)), + height: max(0, snap(rect.size.height)) + ) + } + 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 { @@ -649,6 +800,58 @@ final class WindowTerminalPortal: NSObject { return viewIndex > referenceIndex } + /// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping. + /// SwiftUI/AppKit hosting layers can report an anchor bounds wider than its split pane when + /// intrinsic-size content overflows; intersecting through ancestor bounds gives the effective + /// visible rect that should drive portal geometry. + private func effectiveAnchorFrameInWindow(for anchorView: NSView) -> NSRect { + var frameInWindow = anchorView.convert(anchorView.bounds, to: nil) + var current = anchorView.superview + while let ancestor = current { + let ancestorBoundsInWindow = ancestor.convert(ancestor.bounds, to: nil) + let finiteAncestorBounds = + ancestorBoundsInWindow.origin.x.isFinite && + ancestorBoundsInWindow.origin.y.isFinite && + ancestorBoundsInWindow.size.width.isFinite && + ancestorBoundsInWindow.size.height.isFinite + if finiteAncestorBounds { + frameInWindow = frameInWindow.intersection(ancestorBoundsInWindow) + if frameInWindow.isNull { return .zero } + } + if ancestor === installedReferenceView { break } + current = ancestor.superview + } + return frameInWindow + } + + private func seededFrameInHost(for anchorView: NSView) -> NSRect? { + _ = synchronizeHostFrameToReference() + let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView) + let frameInHostRaw = hostView.convert(frameInWindow, from: nil) + let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) + let hasFiniteFrame = + frameInHost.origin.x.isFinite && + frameInHost.origin.y.isFinite && + frameInHost.size.width.isFinite && + frameInHost.size.height.isFinite + guard hasFiniteFrame else { return nil } + + let hostBounds = hostView.bounds + let hasFiniteHostBounds = + hostBounds.origin.x.isFinite && + hostBounds.origin.y.isFinite && + hostBounds.size.width.isFinite && + hostBounds.size.height.isFinite + if hasFiniteHostBounds { + let clampedFrame = frameInHost.intersection(hostBounds) + if !clampedFrame.isNull, clampedFrame.width > 1, clampedFrame.height > 1 { + return clampedFrame + } + } + + return frameInHost + } + func detachHostedView(withId hostedId: ObjectIdentifier) { guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return } if let anchor = entry.anchorView { @@ -740,6 +943,32 @@ final class WindowTerminalPortal: NSObject { } #endif + _ = synchronizeHostFrameToReference() + + // Seed frame/bounds before entering the window so a freshly reparented + // surface doesn't do a transient 800x600 size update on viewDidMoveToWindow. + if let seededFrame = seededFrameInHost(for: anchorView), + seededFrame.width > 0, + seededFrame.height > 0 { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostedView.frame = seededFrame + hostedView.bounds = NSRect(origin: .zero, size: seededFrame.size) + CATransaction.commit() + } else { + // If anchor geometry is still unsettled, keep this hidden/zero-sized until + // synchronizeHostedView resolves a valid target frame on the next layout tick. + CATransaction.begin() + CATransaction.setDisableActions(true) + hostedView.frame = .zero + hostedView.bounds = .zero + CATransaction.commit() + hostedView.isHidden = true + } + // Keep inner scroll/surface geometry in sync with the seeded outer frame + // before the hosted view enters a window. + hostedView.reconcileGeometryNow() + if hostedView.superview !== hostView { #if DEBUG dlog( @@ -765,10 +994,13 @@ final class WindowTerminalPortal: NSObject { ensureDividerOverlayOnTop() synchronizeHostedView(withId: hostedId) + scheduleDeferredFullSynchronizeAll() pruneDeadEntries() } func synchronizeHostedViewForAnchor(_ anchorView: NSView) { + guard ensureInstalled() else { return } + synchronizeLayoutHierarchy() pruneDeadEntries() let anchorId = ObjectIdentifier(anchorView) let primaryHostedId = hostedByAnchorId[anchorId] @@ -795,6 +1027,7 @@ final class WindowTerminalPortal: NSObject { private func synchronizeAllHostedViews(excluding hostedIdToSkip: ObjectIdentifier?) { guard ensureInstalled() else { return } + synchronizeLayoutHierarchy() pruneDeadEntries() let hostedIds = Array(entriesByHostedId.keys) for hostedId in hostedIds { @@ -837,16 +1070,44 @@ final class WindowTerminalPortal: NSObject { return } - let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) - let frameInHost = hostView.convert(frameInWindow, from: nil) + _ = synchronizeHostFrameToReference() + let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView) + let frameInHostRaw = hostView.convert(frameInWindow, from: nil) + let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) + let hostBounds = hostView.bounds + let hasFiniteHostBounds = + hostBounds.origin.x.isFinite && + hostBounds.origin.y.isFinite && + hostBounds.size.width.isFinite && + hostBounds.size.height.isFinite + let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1 + if !hostBoundsReady { +#if DEBUG + dlog( + "portal.sync.defer hosted=\(portalDebugToken(hostedView)) " + + "reason=hostBoundsNotReady host=\(portalDebugFrame(hostBounds)) " + + "anchor=\(portalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)" + ) +#endif + hostedView.isHidden = true + scheduleDeferredFullSynchronizeAll() + return + } + let hasFiniteFrame = frameInHost.origin.x.isFinite && frameInHost.origin.y.isFinite && frameInHost.size.width.isFinite && frameInHost.size.height.isFinite + let clampedFrame = frameInHost.intersection(hostBounds) + let hasVisibleIntersection = + !clampedFrame.isNull && + clampedFrame.width > 1 && + clampedFrame.height > 1 + let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView) - let tinyFrame = frameInHost.width <= 1 || frameInHost.height <= 1 - let outsideHostBounds = !frameInHost.intersects(hostView.bounds) + let tinyFrame = targetFrame.width <= 1 || targetFrame.height <= 1 + let outsideHostBounds = !hasVisibleIntersection let shouldHide = !entry.visibleInUI || anchorHidden || @@ -856,29 +1117,45 @@ final class WindowTerminalPortal: NSObject { let oldFrame = hostedView.frame #if DEBUG + let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame) + if frameWasClamped { + dlog( + "portal.frame.clamp hosted=\(portalDebugToken(hostedView)) " + + "anchor=\(portalDebugToken(anchorView)) " + + "raw=\(portalDebugFrame(frameInHost)) clamped=\(portalDebugFrame(targetFrame)) " + + "host=\(portalDebugFrame(hostBounds))" + ) + } let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame if collapsedToTiny { dlog( "portal.frame.collapse hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " + - "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))" + "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))" ) } else if restoredFromTiny { dlog( "portal.frame.restore hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " + - "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))" + "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))" ) } #endif - if !Self.rectApproximatelyEqual(oldFrame, frameInHost) { + if hasFiniteFrame && !Self.rectApproximatelyEqual(oldFrame, targetFrame) { CATransaction.begin() CATransaction.setDisableActions(true) - hostedView.frame = frameInHost + hostedView.frame = targetFrame CATransaction.commit() + hostedView.reconcileGeometryNow() + hostedView.refreshSurfaceNow() + } - if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 || - abs(oldFrame.size.height - frameInHost.size.height) > 0.5 { - hostedView.reconcileGeometryNow() + if hasFiniteFrame { + let expectedBounds = NSRect(origin: .zero, size: targetFrame.size) + if !Self.rectApproximatelyEqual(hostedView.bounds, expectedBounds) { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostedView.bounds = expectedBounds + CATransaction.commit() } } @@ -888,12 +1165,25 @@ final class WindowTerminalPortal: NSObject { "portal.hidden hosted=\(portalDebugToken(hostedView)) value=\(shouldHide ? 1 : 0) " + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + - "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))" + "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " + + "host=\(portalDebugFrame(hostBounds))" ) #endif hostedView.isHidden = shouldHide } +#if DEBUG + dlog( + "portal.sync.result hosted=\(portalDebugToken(hostedView)) " + + "anchor=\(portalDebugToken(anchorView)) host=\(portalDebugToken(hostView)) " + + "hostWin=\(hostView.window?.windowNumber ?? -1) " + + "old=\(portalDebugFrame(oldFrame)) raw=\(portalDebugFrame(frameInHost)) " + + "target=\(portalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " + + "entryVisible=\(entry.visibleInUI ? 1 : 0) hostedHidden=\(hostedView.isHidden ? 1 : 0) " + + "hostBounds=\(portalDebugFrame(hostBounds))" + ) +#endif + ensureDividerOverlayOnTop() } @@ -927,6 +1217,7 @@ final class WindowTerminalPortal: NSObject { } func tearDown() { + removeGeometryObservers() for hostedId in Array(entriesByHostedId.keys) { detachHostedView(withId: hostedId) } diff --git a/tests/test_terminal_resize_portal_regressions.py b/tests/test_terminal_resize_portal_regressions.py new file mode 100644 index 00000000..055b5e54 --- /dev/null +++ b/tests/test_terminal_resize_portal_regressions.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Static regression checks for terminal tiny-pane resize/overflow fixes. + +Guards the key invariants for issue #348: +1) Terminal portal sync must stabilize layout and clamp hosted frames to host bounds. +2) Surface sizing must prefer live bounds over stale pending values when available. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path(__file__).resolve().parents[1] + + +def extract_block(source: str, signature: str) -> str: + start = source.find(signature) + if start < 0: + raise ValueError(f"Missing signature: {signature}") + brace_start = source.find("{", start) + if brace_start < 0: + raise ValueError(f"Missing opening brace for: {signature}") + + depth = 0 + for idx in range(brace_start, len(source)): + char = source[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[brace_start : idx + 1] + raise ValueError(f"Unbalanced braces for: {signature}") + + +def main() -> int: + root = repo_root() + failures: list[str] = [] + + portal_path = root / "Sources" / "TerminalWindowPortal.swift" + portal_source = portal_path.read_text(encoding="utf-8") + + if "hostView.layer?.masksToBounds = true" not in portal_source: + failures.append("WindowTerminalPortal init no longer enables hostView layer clipping") + if "hostView.postsFrameChangedNotifications = true" not in portal_source: + failures.append("WindowTerminalPortal init no longer enables hostView frame-change notifications") + if "hostView.postsBoundsChangedNotifications = true" not in portal_source: + failures.append("WindowTerminalPortal init no longer enables hostView bounds-change notifications") + + if "private func synchronizeLayoutHierarchy()" not in portal_source: + failures.append("WindowTerminalPortal missing synchronizeLayoutHierarchy()") + if "private func synchronizeHostFrameToReference() -> Bool" not in portal_source: + failures.append("WindowTerminalPortal missing synchronizeHostFrameToReference()") + if "hostedView.reconcileGeometryNow()" not in extract_block( + portal_source, + "func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0)", + ): + failures.append("bind() no longer pre-reconciles hosted geometry before attach") + + sync_block = extract_block(portal_source, "private func synchronizeHostedView(withId hostedId: ObjectIdentifier)") + for required in [ + "let hostBounds = hostView.bounds", + "let clampedFrame = frameInHost.intersection(hostBounds)", + "let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost", + "scheduleDeferredFullSynchronizeAll()", + "hostedView.reconcileGeometryNow()", + "hostedView.refreshSurfaceNow()", + ]: + if required not in sync_block: + failures.append(f"terminal portal sync missing: {required}") + + terminal_view_path = root / "Sources" / "GhosttyTerminalView.swift" + terminal_view_source = terminal_view_path.read_text(encoding="utf-8") + + resolved_block = extract_block(terminal_view_source, "private func resolvedSurfaceSize(preferred size: CGSize?) -> CGSize") + bounds_index = resolved_block.find("let currentBounds = bounds.size") + pending_index = resolved_block.find("if let pending = pendingSurfaceSize") + if bounds_index < 0 or pending_index < 0 or bounds_index > pending_index: + failures.append("resolvedSurfaceSize() no longer prefers bounds before pendingSurfaceSize") + + update_block = extract_block(terminal_view_source, "private func updateSurfaceSize(size: CGSize? = nil)") + if "let size = resolvedSurfaceSize(preferred: size)" not in update_block: + failures.append("updateSurfaceSize() no longer resolves size via resolvedSurfaceSize()") + + if failures: + print("FAIL: terminal resize/portal regression guards failed") + for item in failures: + print(f" - {item}") + return 1 + + print("PASS: terminal resize/portal regression guards are in place") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/bonsplit b/vendor/bonsplit index c9186860..2d0d05aa 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit c91868601ef27e673ca884639a724f2d10fcd54d +Subproject commit 2d0d05aad8e1c2c1c56c290718063f9b53408849