Fix terminal/web portal overflow during narrow pane resizing
This commit is contained in:
parent
6598a38fe3
commit
4bc3da65b6
5 changed files with 582 additions and 36 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
106
tests/test_terminal_resize_portal_regressions.py
Normal file
106
tests/test_terminal_resize_portal_regressions.py
Normal 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
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit c91868601ef27e673ca884639a724f2d10fcd54d
|
||||
Subproject commit 2d0d05aad8e1c2c1c56c290718063f9b53408849
|
||||
Loading…
Add table
Add a link
Reference in a new issue