Merge pull request #396 from manaflow-ai/issue-348-terminal-overflow-hardfix

Fix pane overflow/misalignment during aggressive resize (issue #348)
This commit is contained in:
Austin Wang 2026-02-23 12:26:22 -08:00 committed by GitHub
commit 8cc5139441
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 582 additions and 36 deletions

View file

@ -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)
}

View file

@ -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,

View file

@ -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)
}

View file

@ -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())

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit c91868601ef27e673ca884639a724f2d10fcd54d
Subproject commit 2d0d05aad8e1c2c1c56c290718063f9b53408849