Files
Outpost/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/ScrollingHeaderController.swift
T
Puranjay Savar Mattas 6e1ffb96fd chore: vendor swift-markdown-engine as plain tracked source
Convert from git submodule to plain vendored copy. No push access to
upstream nodes-app/swift-markdown-engine meant our local fix commit
(05c1720) was stranded and unreachable from any remote on fresh
clones/CI. Vendoring as plain files folds it into normal repo history
instead.
2026-08-20 13:26:13 +01:00

272 lines
13 KiB
Swift

//
// ScrollingHeaderController.swift
// MarkdownEngine
//
// Owns the scroll-away header hosted above the editor body: an embedder-supplied
// SwiftUI view in an `NSHostingView`, inside a clipping band at the top of the
// container document view. The clip's height is the reserved band the body sits
// below; collapsing animates it between the embedder's collapsed height and the
// content's intrinsic height, so the top of the header stays put while the lower
// content reveals/hides.
//
import AppKit
import SwiftUI
@MainActor
final class ScrollingHeaderController {
/// Clip container whose height is the reserved top band. Reveals/hides the
/// lower content while the top stays put.
private(set) var clipView: NSView?
/// Hosts the embedder's full header content, top-pinned inside the clip.
private(set) var hostingView: NSHostingView<AnyView>?
/// Active when expanded: `clip.height == host.height`, so the reserved band
/// always equals the content's (async-resolved) intrinsic height — self-correcting.
private var equalityConstraint: NSLayoutConstraint?
/// Active when collapsed or animating: `clip.height == constant`.
private var constantConstraint: NSLayoutConstraint?
/// Observes the clip's height; the SOLE writer of `container.headerHeight`.
private var clipFrameObserver: NSObjectProtocol?
/// Last applied expanded state, to detect toggles.
private var lastExpanded: Bool?
/// Invalidates stale animation completions when a new toggle interrupts one.
private var animationToken = 0
/// Collapse/expand animation duration. Internal so tests can shrink it.
var animationDuration: TimeInterval = 0.32
/// The reserved top band the body should sit below. When the constant
/// constraint governs (collapsed / animating) this is its `constant` — stable
/// against transient mid-layout clip frames; otherwise the live clip height.
var reservedHeight: CGFloat {
if let constantConstraint, constantConstraint.isActive {
return constantConstraint.constant
}
return clipView?.frame.height ?? 0
}
deinit {
if let clipFrameObserver {
NotificationCenter.default.removeObserver(clipFrameObserver)
}
}
/// Build the header on first call; afterwards refresh the hosted content
/// (every call — the embedder's view may capture changing values) and apply
/// the collapse/expand state.
func reconcile(
header: AnyView,
collapsedHeight: CGFloat,
expanded: Bool,
container: NativeTextViewContainer
) {
if clipView == nil {
build(header: header, collapsedHeight: collapsedHeight, expanded: expanded, container: container)
} else if let hostingView {
// Refresh on every reconcile (every updateNSView, including keystrokes):
// the embedder's view may capture changing values, and SwiftUI's diff of
// an unchanged hierarchy is cheap — the same cost the header would pay
// rendered anywhere else in the embedder's tree. @State inside the
// header survives (same root structure diffs in place).
hostingView.rootView = header
}
applyExpansion(collapsedHeight: collapsedHeight, expanded: expanded, container: container)
}
func remove(from container: NativeTextViewContainer?) {
animationToken += 1
settledToken = animationToken // no animation in flight after teardown
animationTargetHeight = nil
if let clipFrameObserver {
NotificationCenter.default.removeObserver(clipFrameObserver)
self.clipFrameObserver = nil
}
clipView?.removeFromSuperview()
clipView = nil
hostingView = nil
equalityConstraint = nil
constantConstraint = nil
lastExpanded = nil
container?.headerHeight = 0 // → restack: text view back to y=0, container shrinks
}
// MARK: - Internals
private func build(
header: AnyView,
collapsedHeight: CGFloat,
expanded: Bool,
container: NativeTextViewContainer
) {
// Body compositing, gated on header presence so header-less editors keep
// AppKit's default rendering: the body is layer-backed (TextKit 2's default,
// asserted here for the seam fix), redrawn only on explicit invalidation, and
// clipped to its bounds so responsive-scroll OVERDRAW can't render text above
// the frame top into the header band. NSView does NOT clip by default; without
// this the body bleeds up over the collapsed header even though the frames
// are disjoint. Left in place if the header is later removed — un-clipping a
// live text view buys nothing and risks a repaint glitch.
if let textView = container.textView {
textView.wantsLayer = true
textView.layerContentsRedrawPolicy = .onSetNeedsDisplay
textView.clipsToBounds = true
}
let host = NSHostingView(rootView: header)
if #available(macOS 13.0, *) { host.sizingOptions = [.intrinsicContentSize] }
// Ignore the window safe area (the toolbar/navbar region). Otherwise, as the
// host scrolls relative to that safe area, SwiftUI adds/removes a TOP inset to
// "flow under the navbar" — which shifts the content down inside the host,
// pushes the collapsed band's content below the clip mask, and makes the
// measured intrinsic height oscillate with the scroll position.
if #available(macOS 13.3, *) { host.safeAreaRegions = [] }
let clip = NSView()
// Layer-back the clip so `clipsToBounds` reliably masks the layer-backed
// NSHostingView; without it the inspector body bleeds below the collapsed
// band on async resize (ghost rows + stale heading paints).
clip.wantsLayer = true
clip.translatesAutoresizingMaskIntoConstraints = false
clip.clipsToBounds = true
clip.postsFrameChangedNotifications = true
// The clip is a SIBLING of the text view inside the container (top band).
// Auto-Layout-pinned to the container's top/leading/trailing; its height is
// owned by the equality/constant constraint pair below and read back into
// `container.headerHeight`.
container.addSubview(clip)
host.translatesAutoresizingMaskIntoConstraints = false
clip.addSubview(host)
// Two height options for the clip; exactly one is active at a time.
// • equality: clip.height == host.height → expanded, tracks content live.
// • constant: clip.height == <value> → collapsed, or animating.
let equalityC = clip.heightAnchor.constraint(equalTo: host.heightAnchor)
let constantC = clip.heightAnchor.constraint(equalToConstant: max(0, collapsedHeight))
NSLayoutConstraint.activate([
clip.topAnchor.constraint(equalTo: container.topAnchor),
clip.leadingAnchor.constraint(equalTo: container.leadingAnchor),
clip.trailingAnchor.constraint(equalTo: container.trailingAnchor),
// Host is full-height (top-pinned); overflow below the clip is hidden.
host.topAnchor.constraint(equalTo: clip.topAnchor),
host.leadingAnchor.constraint(equalTo: clip.leadingAnchor),
host.trailingAnchor.constraint(equalTo: clip.trailingAnchor)
])
if expanded { equalityC.isActive = true } else { constantC.isActive = true }
hostingView = host
clipView = clip
equalityConstraint = equalityC
constantConstraint = constantC
lastExpanded = expanded
// SOLE writer of `container.headerHeight`: the clip's height drives the header
// band, which the container reads to offset the text view. Synchronous (queue
// nil) so the body tracks the header with no lag. `reservedHeight` reads the
// constant's intended value while it governs — a scroll-time layout pass can
// momentarily expose a smaller in-flight clip frame.
clipFrameObserver = NotificationCenter.default.addObserver(
forName: NSView.frameDidChangeNotification, object: clip, queue: nil
) { [weak self, weak container] _ in
MainActor.assumeIsolated {
guard let self, let container else { return }
let h = self.reservedHeight
guard abs(container.headerHeight - h) > 0.1 else { return }
container.headerHeight = h
}
}
container.layoutSubtreeIfNeeded()
container.headerHeight = reservedHeight
}
private func applyExpansion(
collapsedHeight: CGFloat,
expanded: Bool,
container: NativeTextViewContainer
) {
guard let equalityC = equalityConstraint,
let constantC = constantConstraint,
let clip = clipView,
let host = hostingView else { return }
let collapsed = max(0, collapsedHeight)
if lastExpanded != expanded {
lastExpanded = expanded
// Hand the clip height to the animatable constant constraint.
let start = clip.frame.height
equalityC.isActive = false
constantC.constant = start
constantC.isActive = true
let target: CGFloat
if expanded {
host.invalidateIntrinsicContentSize()
host.layoutSubtreeIfNeeded()
target = max(collapsed, host.fittingSize.height)
} else {
target = collapsed
}
animate(to: target, expandedAfter: expanded, container: container)
} else if !expanded, constantC.isActive {
if animationToken != settledToken {
// A collapse animation is in flight. If the collapsed height changed
// mid-flight (e.g. the pinned row re-measured), retarget — the new
// value arrives only on this reconcile and would otherwise be lost.
if let target = animationTargetHeight, abs(target - collapsed) > 0.5 {
animate(to: collapsed, expandedAfter: false, container: container)
}
} else if abs(constantC.constant - collapsed) > 0.5 {
// Collapsed steady: keep the constant in sync with the collapsed height.
constantC.constant = collapsed
container.layoutSubtreeIfNeeded()
}
}
// Expanded steady: the equality constraint already tracks the content.
}
/// Tracks whether an animation is in flight: `animationToken` advances on every
/// animation start and interruption; `settledToken` catches up on settle.
private var settledToken = 0
/// Target of the in-flight animation, for mid-flight retargeting.
private var animationTargetHeight: CGFloat?
private func animate(to target: CGFloat, expandedAfter: Bool, container: NativeTextViewContainer) {
guard let constantC = constantConstraint else { return }
animationToken += 1
let token = animationToken
animationTargetHeight = target
func settle() {
guard token == animationToken else { return } // interrupted by a newer toggle
settledToken = token
animationTargetHeight = nil
if expandedAfter, let equalityC = equalityConstraint, let constantC = constantConstraint {
// Hand back to the live-tracking equality constraint.
constantC.isActive = false
equalityC.isActive = true
clipView?.superview?.layoutSubtreeIfNeeded()
}
// One clamp once the height has settled (skipped during the animation).
(container.enclosingScrollView as? ClampedScrollView)?.clampToInsets()
}
// ALWAYS go through the animator, even for a no-distance move: a direct
// property set would not cancel an animation already in flight on the
// constant, which would keep ticking toward its stale target underneath us.
let start = constantC.constant
let instant = abs(target - start) <= 0.5
NSAnimationContext.runAnimationGroup({ context in
context.duration = instant ? 0 : animationDuration
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
// Animating the constraint's constant marks the container needing layout
// each frame; the window's display cycle runs the layout pass, the clip's
// frame change fires the observer, and the body tracks the band.
constantC.animator().constant = target
}, completionHandler: {
MainActor.assumeIsolated { settle() }
})
if instant { container.layoutSubtreeIfNeeded() }
}
}