Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Glint/Pane/GhosttySurfaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: "")
Expand Down
38 changes: 31 additions & 7 deletions Glint/Pane/PaneSurfaceRepresentable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
151 changes: 117 additions & 34 deletions Glint/Pane/PaneTreeView.swift
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
78 changes: 76 additions & 2 deletions GlintTests/PerformanceRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down