diff --git a/Glint/Pane/GhosttySurfaceView.swift b/Glint/Pane/GhosttySurfaceView.swift index c00006d..d9f1887 100644 --- a/Glint/Pane/GhosttySurfaceView.swift +++ b/Glint/Pane/GhosttySurfaceView.swift @@ -34,6 +34,11 @@ struct SurfaceFocusUpdateGate { final class GhosttySurfaceView: NSView, NSTextInputClient { private var surface: ghostty_surface_t? + /// The newest SwiftUI/AppKit container allowed to host this stable surface. + /// Split-tree reshapes can briefly leave both the outgoing and incoming + /// representables alive; the older one must not re-parent the surface back. + weak var paneHostView: NSView? + var paneHostGeneration: UInt64 = 0 private var focusUpdateGate = SurfaceFocusUpdateGate() private var trackingArea: NSTrackingArea? private var markedTextValue: NSAttributedString = NSAttributedString(string: "") diff --git a/Glint/Pane/PaneSurfaceRepresentable.swift b/Glint/Pane/PaneSurfaceRepresentable.swift index 183e392..2c0c720 100644 --- a/Glint/Pane/PaneSurfaceRepresentable.swift +++ b/Glint/Pane/PaneSurfaceRepresentable.swift @@ -4,8 +4,18 @@ import AppKit enum SurfaceReassertionPolicy { static func shouldReassert(containerIsAttached: Bool, expectedSurfaceMatches: Bool, - paneIsVisible: Bool) -> Bool { - containerIsAttached && expectedSurfaceMatches && paneIsVisible + paneIsVisible: Bool, + hostClaimMatches: Bool) -> Bool { + containerIsAttached && expectedSurfaceMatches && paneIsVisible && hostClaimMatches + } +} + +enum SurfaceHostClaimPolicy { + static func shouldClaim(candidateGeneration: UInt64, + currentGeneration: UInt64, + currentHostIsAttached: Bool, + isSameHost: Bool) -> Bool { + isSameHost || !currentHostIsAttached || candidateGeneration >= currentGeneration } } @@ -90,11 +100,13 @@ struct PaneSurfaceRepresentable: NSViewRepresentable { /// row falls on a slightly different sub-pixel offset — the eye reads /// the result as a 1px "fault line" tearing through the rows. final class NoDragContainerView: NSView { + /// Creation order is the tie-breaker while an outgoing and incoming + /// split-tree host coexist. New tree containers must win even if an + /// old representable receives a late update. + let hostGeneration = mach_absolute_time() + /// The surface this container most recently claimed via `attach`. - /// Read by the post-commit recheck to tell whether this container is - /// still the surface's rightful host (a later attach to a different - /// container overwrites the claim there, not here, so identity of - /// the pair (container, surface) is what's being verified). + /// Paired with the surface-side host claim for the post-commit recheck. weak var expectedSurface: GhosttySurfaceView? override var mouseDownCanMoveWindow: Bool { false } @@ -134,6 +146,16 @@ struct PaneSurfaceRepresentable: NSViewRepresentable { } private func attach(_ surface: GhosttySurfaceView, to container: NoDragContainerView) { + let currentHost = surface.paneHostView + guard SurfaceHostClaimPolicy.shouldClaim( + candidateGeneration: container.hostGeneration, + currentGeneration: surface.paneHostGeneration, + currentHostIsAttached: currentHost?.window != nil, + isSameHost: currentHost === container + ) else { return } + + surface.paneHostView = container + surface.paneHostGeneration = container.hostGeneration container.expectedSurface = surface Self.pin(surface, in: container) // When the split tree reshapes (workspace switch, pane close), SwiftUI @@ -149,7 +171,9 @@ struct PaneSurfaceRepresentable: NSViewRepresentable { guard SurfaceReassertionPolicy.shouldReassert( containerIsAttached: container.window != nil, expectedSurfaceMatches: container.expectedSurface === surface, - paneIsVisible: isPaneVisible() + paneIsVisible: isPaneVisible(), + hostClaimMatches: surface.paneHostView === container && + surface.paneHostGeneration == container.hostGeneration ) else { return } Self.pin(surface, in: container) } diff --git a/Glint/Pane/PaneTreeView.swift b/Glint/Pane/PaneTreeView.swift index cfe4f4d..395adcf 100644 --- a/Glint/Pane/PaneTreeView.swift +++ b/Glint/Pane/PaneTreeView.swift @@ -1,6 +1,99 @@ import SwiftUI import AppKit +enum SplitLayoutPolicy { + static let dividerLength: CGFloat = 1 + + static func lengths(total: CGFloat, + ratio: CGFloat, + minPaneLength: CGFloat) -> (first: CGFloat, second: CGFloat) { + guard total > dividerLength else { return (0, 0) } + let minFraction = min(minPaneLength / total, 0.5) + let clamped = min(max(ratio, minFraction), 1 - minFraction) + let first = (total * clamped).rounded(.down) + return (first, max(total - dividerLength - first, 0)) + } +} + +final class SplitDragHandleView: NSView { + var isHorizontal = true { + didSet { discardCursorRects() } + } + var onTranslation: ((CGFloat) -> Void)? + var onEnded: (() -> Void)? + var onHover: ((Bool) -> Void)? + + private var dragStart: NSPoint? + + override var mouseDownCanMoveWindow: Bool { false } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + override func resetCursorRects() { + addCursorRect( + bounds, + cursor: isHorizontal ? .resizeLeftRight : .resizeUpDown + ) + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach(removeTrackingArea) + addTrackingArea(NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + )) + } + + override func mouseEntered(with event: NSEvent) { + (isHorizontal ? NSCursor.resizeLeftRight : NSCursor.resizeUpDown).set() + onHover?(true) + } + + override func mouseExited(with event: NSEvent) { + NSCursor.arrow.set() + onHover?(false) + } + + override func mouseDown(with event: NSEvent) { + dragStart = event.locationInWindow + onTranslation?(0) + } + + override func mouseDragged(with event: NSEvent) { + guard let dragStart else { return } + let current = event.locationInWindow + onTranslation?( + isHorizontal ? current.x - dragStart.x : dragStart.y - current.y + ) + } + + override func mouseUp(with event: NSEvent) { + guard dragStart != nil else { return } + dragStart = nil + onEnded?() + } +} + +private struct SplitDragHandle: NSViewRepresentable { + let isHorizontal: Bool + let onTranslation: (CGFloat) -> Void + let onEnded: () -> Void + let onHover: (Bool) -> Void + + func makeNSView(context: Context) -> SplitDragHandleView { + SplitDragHandleView() + } + + func updateNSView(_ nsView: SplitDragHandleView, context: Context) { + nsView.isHorizontal = isHorizontal + nsView.onTranslation = onTranslation + nsView.onEnded = onEnded + nsView.onHover = onHover + } +} + struct PaneTreeView: View { let node: SplitNode /// The workspace this tree belongs to, captured by value at render time. @@ -53,7 +146,12 @@ private struct SplitContainer: View { var body: some View { GeometryReader { geo in let total = isHorizontal ? geo.size.width : geo.size.height - let firstLength = firstLength(total: total) + let lengths = SplitLayoutPolicy.lengths( + total: total, + ratio: ratio, + minPaneLength: Self.minPaneLength + ) + let firstLength = lengths.first ZStack(alignment: .topLeading) { if isHorizontal { @@ -62,64 +160,49 @@ private struct SplitContainer: View { .frame(width: firstLength) divider PaneTreeView(node: b, workspaceID: workspaceID, path: path + [true]) + .frame(width: lengths.second) } + .frame(width: geo.size.width, height: geo.size.height, alignment: .topLeading) } else { VStack(spacing: 0) { PaneTreeView(node: a, workspaceID: workspaceID, path: path + [false]) .frame(height: firstLength) divider PaneTreeView(node: b, workspaceID: workspaceID, path: path + [true]) + .frame(height: lengths.second) } + .frame(width: geo.size.width, height: geo.size.height, alignment: .topLeading) } // The visible divider stays 1px so panes butt up against // each other like before; the grabbable area is this wider // transparent strip floating on top of the seam. - Color.clear + SplitDragHandle( + isHorizontal: isHorizontal, + onTranslation: { translation in + let base = dragBaseRatio ?? ratio + if dragBaseRatio == nil { dragBaseRatio = base } + guard total > 0 else { return } + let minFraction = min(Self.minPaneLength / total, 0.5) + let next = min(max(base + translation / total, minFraction), + 1 - minFraction) + store.setSplitRatio(path: path, ratio: next) + }, + onEnded: { dragBaseRatio = nil }, + onHover: { hovering = $0 } + ) .frame( width: isHorizontal ? 9 : geo.size.width, height: isHorizontal ? geo.size.height : 9 ) - .contentShape(Rectangle()) .offset( x: isHorizontal ? firstLength - 4 : 0, y: isHorizontal ? 0 : firstLength - 4 ) - .onHover { inside in - hovering = inside - if inside { - (isHorizontal ? NSCursor.resizeLeftRight : NSCursor.resizeUpDown).push() - } else { - NSCursor.pop() - } - } - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - let base = dragBaseRatio ?? ratio - if dragBaseRatio == nil { dragBaseRatio = base } - guard total > 0 else { return } - let delta = (isHorizontal ? value.translation.width : value.translation.height) / total - let minFraction = min(Self.minPaneLength / total, 0.5) - let next = min(max(base + delta, minFraction), 1 - minFraction) - store.setSplitRatio(path: path, ratio: next) - } - .onEnded { _ in dragBaseRatio = nil } - ) } } } - private func firstLength(total: CGFloat) -> CGFloat { - guard total > 1 else { return 0 } - let minFraction = min(Self.minPaneLength / total, 0.5) - let clamped = min(max(ratio, minFraction), 1 - minFraction) - // Floor to whole points so the ghostty surfaces sit on integral - // boundaries (fractional frames cause the scroll "fault line" — - // see NoDragContainerView in PaneSurfaceRepresentable). - return (total * clamped).rounded(.down) - } - private var divider: some View { Rectangle() .fill(hovering ? Theme.overlay(0.18) : Theme.divider) diff --git a/GlintTests/PerformanceRegressionTests.swift b/GlintTests/PerformanceRegressionTests.swift index 14ba331..e1ccc06 100644 --- a/GlintTests/PerformanceRegressionTests.swift +++ b/GlintTests/PerformanceRegressionTests.swift @@ -149,15 +149,89 @@ final class PerformanceRegressionTests: XCTestCase { XCTAssertTrue(SurfaceReassertionPolicy.shouldReassert( containerIsAttached: true, expectedSurfaceMatches: true, - paneIsVisible: true + paneIsVisible: true, + hostClaimMatches: true )) XCTAssertFalse(SurfaceReassertionPolicy.shouldReassert( containerIsAttached: true, expectedSurfaceMatches: true, - paneIsVisible: false + paneIsVisible: false, + hostClaimMatches: true )) } + func testDelayedSurfaceReassertRejectsSupersededHost() { + XCTAssertFalse(SurfaceReassertionPolicy.shouldReassert( + containerIsAttached: true, + expectedSurfaceMatches: true, + paneIsVisible: true, + hostClaimMatches: false + )) + } + + func testOlderSurfaceHostCannotStealFromAttachedNewerHost() { + XCTAssertFalse(SurfaceHostClaimPolicy.shouldClaim( + candidateGeneration: 10, + currentGeneration: 11, + currentHostIsAttached: true, + isSameHost: false + )) + } + + func testOlderSurfaceHostCanRecoverAfterNewerHostDetaches() { + XCTAssertTrue(SurfaceHostClaimPolicy.shouldClaim( + candidateGeneration: 10, + currentGeneration: 11, + currentHostIsAttached: false, + isSameHost: false + )) + } + + func testNewestHostWinsDeterministicOutgoingIncomingOutgoingRace() { + let outgoingHost = NSView() + let incomingHost = NSView() + let surface = NSView() + outgoingHost.addSubview(surface) + + var currentHost: NSView = outgoingHost + var currentGeneration: UInt64 = 10 + func claim(_ candidate: NSView, generation: UInt64) { + guard SurfaceHostClaimPolicy.shouldClaim( + candidateGeneration: generation, + currentGeneration: currentGeneration, + currentHostIsAttached: true, + isSameHost: currentHost === candidate + ) else { return } + surface.removeFromSuperview() + candidate.addSubview(surface) + currentHost = candidate + currentGeneration = generation + } + + claim(incomingHost, generation: 11) + claim(outgoingHost, generation: 10) // delayed stale-tree callback + + XCTAssertTrue(surface.superview === incomingHost) + XCTAssertTrue(currentHost === incomingHost) + XCTAssertEqual(currentGeneration, 11) + } + + func testSplitLayoutExplicitlyAccountsForBothBranchesAndDivider() { + let lengths = SplitLayoutPolicy.lengths( + total: 1_556, + ratio: 0.5481854514781491, + minPaneLength: 100 + ) + + XCTAssertEqual(lengths.first, 852) + XCTAssertEqual(lengths.second, 703) + XCTAssertEqual(lengths.first + SplitLayoutPolicy.dividerLength + lengths.second, 1_556) + } + + func testSplitHandleCannotMoveBorderlessWindow() { + XCTAssertFalse(SplitDragHandleView().mouseDownCanMoveWindow) + } + func testPaneVisibilityRequiresSelectedWorkspaceAndSelectedTab() { let wsID = UUID() let onSelectedTab = PaneID(value: 1)