diff --git a/README.md b/README.md index c87c82a..b27af72 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,40 @@ NativeTextViewWrapper( replacement (e.g. an autocomplete result); the engine consumes it and clears the binding. +### Height Behavior + +By default the editor scrolls internally within whatever height SwiftUI +gives it. Set `heightBehavior` to `.fitsContent` to make the editor grow +to fit its content and report that height to SwiftUI, so an enclosing +`ScrollView` scrolls the page instead: + +```swift +ScrollView { + NativeTextViewWrapper( + text: $text, + configuration: .init(heightBehavior: .fitsContent) + ) +} +``` + +- The editor reports `headerHeight + text content height` to SwiftUI; + no inner scroller appears. +- Typing additional lines grows the block; deleting lines shrinks it. +- An empty document shows one line of height. +- Scroll-wheel events pass through to the enclosing scroll view. +- Composes with `readingWidth`: the centered column is preserved and + height grows to the column's content height. +- A static scroll-away header's band is included in the reported height. + The collapse-on-scroll animation is not meaningful in `.fitsContent` + because there is no internal scroll offset to drive it. +- Switching `heightBehavior` at runtime is supported; the editor + reconfigures immediately. + +**Trade-offs:** `.fitsContent` forces full-document layout so the total +height is known, forgoing TextKit-2 viewport virtualization. This is +fine for small-to-medium inline content; very large documents still work +but lay out in full. + ### Reading Column Give long documents a fixed-width, centered column — and let wide GFM diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index ab6f2bd..181ed87 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -47,6 +47,22 @@ public struct MarkdownEditorConfiguration: Sendable { /// Centered reading-column width; wide tables break out to full width. nil = full width (default). public var readingWidth: CGFloat? public var spellChecking: SpellCheckingPolicy + /// How the editor resolves its own height. + /// + /// - `.scrolls` (default): the editor scrolls internally within whatever + /// height SwiftUI gives it. This is the historical behavior. + /// - `.fitsContent`: the editor grows to fit its content and reports that + /// height to SwiftUI, so an enclosing `ScrollView` scrolls the page + /// instead of a nested internal scroller. The editor re-reports its + /// height per keystroke as well as after async content changes (image + /// loads, font-size changes, header band resizes). + /// + /// Switching at runtime is supported; the editor reconfigures immediately + /// (scroller visibility, overscroll, inflation, and intrinsic size all + /// update in the same SwiftUI update cycle). + /// + /// - SeeAlso: ``HeightBehavior`` + public var heightBehavior: HeightBehavior public init( theme: MarkdownEditorTheme = .default, @@ -68,7 +84,8 @@ public struct MarkdownEditorConfiguration: Sendable { scrollers: ScrollersPolicy = .default, textInsets: TextInsets = .default, readingWidth: CGFloat? = nil, - spellChecking: SpellCheckingPolicy = .default + spellChecking: SpellCheckingPolicy = .default, + heightBehavior: HeightBehavior = .scrolls ) { self.theme = theme self.services = services @@ -90,6 +107,7 @@ public struct MarkdownEditorConfiguration: Sendable { self.textInsets = textInsets self.readingWidth = readingWidth self.spellChecking = spellChecking + self.heightBehavior = heightBehavior } public static let `default` = MarkdownEditorConfiguration() @@ -523,3 +541,76 @@ public struct SafeAreaInsets: Sendable { public static let `default` = SafeAreaInsets() } + +// MARK: - Height behavior + +extension MarkdownEditorConfiguration { + /// How the editor resolves its own height. + /// + /// ## Usage + /// + /// ```swift + /// // Inline editor inside a page scroll view: + /// ScrollView { + /// NativeTextViewWrapper( + /// text: $text, + /// configuration: .init(heightBehavior: .fitsContent) + /// ) + /// } + /// ``` + /// + /// ## Behavior + /// + /// In `.fitsContent` mode: + /// - The editor reports `headerHeight + text content height` to SwiftUI. + /// - Typing grows/shrinks the block per keystroke; SwiftUI re-lays-out. + /// - An empty document shows at least one body line of height. + /// - Scroll-wheel events pass through to the enclosing scroll view. + /// - Caret visibility propagates to the enclosing (page-level) scroll + /// view so editing at the bottom of a tall block keeps the caret + /// on-screen. + /// - Async content changes (image/LaTeX finishing layout, font-size + /// change) re-report size via `invalidateIntrinsicContentSize`. + /// - Switching between `.scrolls` and `.fitsContent` at runtime is + /// supported; the editor reconfigures immediately. + /// + /// ## Composition + /// + /// - **Reading column** (`readingWidth`): the centered fixed-width column + /// is preserved; height grows to the column's content height. + /// - **Scroll-away header**: a static header's band is included in the + /// reported height. The collapse-on-scroll animation is driven by the + /// inner scroll offset, which is always zero in `.fitsContent`, so the + /// collapse never triggers. Combining a collapsing header with + /// `.fitsContent` is allowed but the collapse behavior is not meaningful. + /// + /// ## Trade-offs + /// + /// `.fitsContent` forces full-document layout so the total height is known. + /// For small-to-medium documents this is fine; for very large documents it + /// forgoes TextKit-2 viewport virtualization. + public enum HeightBehavior: Sendable { + /// The editor scrolls internally within the height SwiftUI gives it. + /// This is the historical behavior and the default. + case scrolls + + /// The editor grows to fit its content and reports that height back to + /// SwiftUI, so an enclosing scroll view / page scrolls instead of a + /// nested scroll view. Internal scrolling and bottom-overscroll slack + /// are disabled in this mode. + case fitsContent + + /// Whether the vertical scroller should be shown for this height + /// behavior and scroller policy combination. + /// + /// In `.fitsContent` the editor never scrolls internally, so the + /// vertical scroller is always hidden regardless of the policy. + /// In `.scrolls` the policy's `hasVerticalScroller` is respected. + public func wantsVerticalScroller(for scrollers: ScrollersPolicy) -> Bool { + switch self { + case .fitsContent: return false + case .scrolls: return scrollers.hasVerticalScroller + } + } + } +} diff --git a/Sources/MarkdownEngine/TextView/ClampedScrollView.swift b/Sources/MarkdownEngine/TextView/ClampedScrollView.swift index 2c21af3..f28c898 100644 --- a/Sources/MarkdownEngine/TextView/ClampedScrollView.swift +++ b/Sources/MarkdownEngine/TextView/ClampedScrollView.swift @@ -9,21 +9,43 @@ import AppKit final class ClampedScrollView: NSScrollView { + /// When true, the scroll view has no scrollable range — the editor reports its + /// own height to SwiftUI and the enclosing scroll view owns paging. + var fitsContent: Bool = false + /// Saved at the start of every live-resize (including spurious one-click resizes triggered by edge-cursor clicks) so the position is restored when the resize ends. Without this, NSScrollView's default top-anchor-during-resize would jolt a bottom-anchored user back up by hundreds of points on a single edge click. private var scrollYBeforeLiveResize: CGFloat? + override var intrinsicContentSize: NSSize { + guard fitsContent, let container = documentView as? NativeTextViewContainer else { + return super.intrinsicContentSize + } + return NSSize(width: NSView.noIntrinsicMetric, height: container.scrollableContentHeight) + } + override func scrollWheel(with event: NSEvent) { + if fitsContent { + // No scrollable range — forward to the responder chain so the + // enclosing (SwiftUI) scroll view receives the event. In the + // standard SwiftUI hosting layout nextResponder is the clip view's + // superview; if the hosting hierarchy ever differs, AppKit's + // default responder-chain traversal still routes the event up. + nextResponder?.scrollWheel(with: event) + return + } super.scrollWheel(with: event) clampToInsets() } override func viewWillStartLiveResize() { super.viewWillStartLiveResize() + guard !fitsContent else { return } scrollYBeforeLiveResize = contentView.bounds.origin.y } override func viewDidEndLiveResize() { super.viewDidEndLiveResize() + guard !fitsContent else { return } if let y = scrollYBeforeLiveResize { contentView.scroll(to: NSPoint(x: contentView.bounds.origin.x, y: y)) reflectScrolledClipView(contentView) @@ -33,6 +55,7 @@ final class ClampedScrollView: NSScrollView { } func clampToInsets() { + guard !fitsContent else { return } guard let doc = documentView else { return } let minY = -contentInsets.top // Use the real content height (not the inflated frame) so small diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift index 14afce1..37285f1 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift @@ -69,6 +69,8 @@ extension NativeTextView { visibleHeight: CGFloat, lineHeight: CGFloat ) -> CGFloat { + // Overscroll is a scroll-comfort affordance; meaningless without internal scrolling. + guard configuration.heightBehavior == .scrolls else { return 0 } let headerHeight = (superview as? NativeTextViewContainer)?.headerHeight ?? 0 let policy = BottomOverscrollPolicy( overscrollPercent: overscrollPercent, @@ -185,12 +187,18 @@ extension NativeTextView { func applyManagedFrameSize(width: CGFloat) { let contentHeight = max(ceil(baseContentHeight + activeBottomOverscroll), 0) - // The container stacks a header band ABOVE this text view, so the text view only - // needs to fill the viewport MINUS that band for the whole document view to fill - // the viewport on short docs (header + textView ≥ viewport). - let headerH = (superview as? NativeTextViewContainer)?.headerHeight ?? 0 - let scrollViewHeight = max((enclosingScrollView?.contentView.bounds.height ?? 0) - headerH, 0) - let height = max(contentHeight, scrollViewHeight) + let height: CGFloat + switch configuration.heightBehavior { + case .scrolls: + // The container stacks a header band ABOVE this text view, so the text view only + // needs to fill the viewport MINUS that band for the whole document view to fill + // the viewport on short docs (header + textView ≥ viewport). + let headerH = (superview as? NativeTextViewContainer)?.headerHeight ?? 0 + let scrollViewHeight = max((enclosingScrollView?.contentView.bounds.height ?? 0) - headerH, 0) + height = max(contentHeight, scrollViewHeight) + case .fitsContent: + height = contentHeight + } // Reading column: the column keeps its fixed wrap width; its centered X is // owned by `centerReadingColumn` (driven from the container's restack). let targetWidth = configuration.readingWidth != nil ? readingColumnWidth : max(width, 0) @@ -207,6 +215,12 @@ extension NativeTextView { // Tell the container our height changed so it can re-stack (move us below the // header) and size itself. Re-entrancy is guarded inside the container. (superview as? NativeTextViewContainer)?.textViewDidResize() + + // Nudge SwiftUI to re-query sizeThatFits when content height changes outside + // the text binding (e.g. image/LaTeX load, font-size change, header band). + if configuration.heightBehavior == .fitsContent { + enclosingScrollView?.invalidateIntrinsicContentSize() + } } /// Re-center the column by moving its X (not resizing it) so it stays smooth during live resize. @@ -282,6 +296,16 @@ extension NativeTextView { suppressAutoRevealOnce = false return } + if configuration.heightBehavior == .fitsContent { + // The inner scroll view has no scrollable range, so the default + // scrollRangeToVisible does nothing useful. Propagate the caret rect + // to the enclosing (page-level) scroll view so it keeps the caret + // on-screen. AppKit's scrollRectToVisible propagation can stall at + // a nested NSScrollView boundary, so we walk up explicitly. + super.scrollRangeToVisible(range) + propagateCaretRevealToEnclosingScroller(range: range) + return + } // Only the reading column needs manual reveal; default keeps AppKit's native implementation. guard configuration.readingWidth != nil else { super.scrollRangeToVisible(range) @@ -319,6 +343,41 @@ extension NativeTextView { } } + /// Walk the view hierarchy above the inner scroll view to find the + /// enclosing (page-level) scroller and ask it to reveal the caret rect. + /// Used in `.fitsContent` where the inner scroll view cannot scroll. + private func propagateCaretRevealToEnclosingScroller(range: NSRange) { + guard let innerScrollView = enclosingScrollView, + let tlm = textLayoutManager, + let start = tlm.textContentManager?.location( + tlm.documentRange.location, offsetBy: range.location + ) else { return } + // Compute the caret rect in window coordinates so we can convert it + // into whichever enclosing scroller we find. + var caretRect: CGRect? + tlm.enumerateTextLayoutFragments(from: start, options: [.ensuresLayout]) { fragment in + caretRect = fragment.layoutFragmentFrame.offsetBy(dx: 0, dy: self.frame.origin.y) + return false + } + guard let rect = caretRect else { return } + // Convert from document-view space (container) to the inner scroll + // view's coordinate space, then to window, so we can convert into + // any ancestor we find. + let container = innerScrollView.documentView ?? self + let rectInWindow = container.convert(rect, to: nil) + // Walk up past the inner scroll view looking for a parent NSScrollView. + var view: NSView? = innerScrollView.superview + while let v = view { + if let outerScrollView = v as? NSScrollView, outerScrollView !== innerScrollView { + guard let outerDocView = outerScrollView.documentView else { return } + let rectInOuter = outerDocView.convert(rectInWindow, from: nil) + outerDocView.scrollToVisible(rectInOuter) + return + } + view = v.superview + } + } + /// Force TextKit 2 to lay out all fragments within the current visible rect. func ensureVisibleLayout() { guard let tlm = textLayoutManager else { return } diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewContainer.swift b/Sources/MarkdownEngine/TextView/NativeTextViewContainer.swift index 288c24e..c109604 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewContainer.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewContainer.swift @@ -89,7 +89,9 @@ final class NativeTextViewContainer: NSView { textView.shiftWideTableOverlays(byY: deltaY) } let viewportH = enclosingScrollView?.contentView.bounds.height ?? 0 - let totalH = max(headerHeight + textView.frame.height, viewportH) + let stacked = headerHeight + textView.frame.height + let totalH = (textView.configuration.heightBehavior == .fitsContent) ? stacked + : max(stacked, viewportH) if abs(frame.height - totalH) > 0.5 { setFrameSize(NSSize(width: w, height: totalH)) } diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index 5b4c64c..59d1ed7 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -20,6 +20,27 @@ import AppKit /// and callback closures (link clicks, caret movement, inline-selection and /// code-block change notifications). All visual styling and external /// dependencies are routed through ``MarkdownEditorConfiguration``. +/// +/// ### Fit-to-content height +/// +/// Set ``MarkdownEditorConfiguration/heightBehavior`` to `.fitsContent` to +/// make the editor report its content height to SwiftUI instead of scrolling +/// internally. Wrap the editor in a `ScrollView` so the page scrolls: +/// +/// ```swift +/// ScrollView { +/// NativeTextViewWrapper( +/// text: $text, +/// configuration: .init(heightBehavior: .fitsContent) +/// ) +/// } +/// ``` +/// +/// In `.fitsContent` mode the editor grows/shrinks per keystroke, scroll- +/// wheel events pass through to the enclosing scroller, and caret visibility +/// propagates to the enclosing (page-level) scroll view. The reading column +/// (`readingWidth`) composes naturally. See ``MarkdownEditorConfiguration/HeightBehavior`` +/// for the full behavior contract and trade-offs. public struct NativeTextViewWrapper: NSViewRepresentable { public typealias Coordinator = NativeTextViewCoordinator public typealias NSViewType = NSScrollView @@ -138,10 +159,32 @@ public struct NativeTextViewWrapper: NSViewRepresentable { self.retainedScrollDocumentIds = retainedScrollDocumentIds } + public func sizeThatFits( + _ proposal: ProposedViewSize, + nsView: NSScrollView, + context: Context + ) -> CGSize? { + guard configuration.heightBehavior == .fitsContent, + let container = nsView.documentView as? NativeTextViewContainer else { + return nil + } + let width = proposal.width ?? nsView.contentView.bounds.width + // Height is taken from the most recent layout pass rather than re-measured + // at `proposal.width`. Re-measuring TextKit content at a speculative width + // inside sizeThatFits risks layout loops (TextKit relayout → frame change → + // sizeThatFits re-entry) and is expensive for large documents. In practice + // SwiftUI calls sizeThatFits after the view has already been laid out at the + // proposed width, and `invalidateIntrinsicContentSize` in + // `applyManagedFrameSize` ensures SwiftUI re-queries after every width-driven + // relayout, so the returned height stays correct. + return CGSize(width: width, height: container.scrollableContentHeight) + } + public func makeNSView(context: Context) -> NSScrollView { let scrollView = ClampedScrollView() + scrollView.fitsContent = configuration.heightBehavior == .fitsContent scrollView.borderType = .noBorder - scrollView.hasVerticalScroller = configuration.scrollers.hasVerticalScroller + scrollView.hasVerticalScroller = configuration.heightBehavior.wantsVerticalScroller(for: configuration.scrollers) scrollView.hasHorizontalScroller = configuration.scrollers.hasHorizontalScroller scrollView.autohidesScrollers = configuration.scrollers.autohidesScrollers scrollView.drawsBackground = false @@ -262,7 +305,10 @@ public struct NativeTextViewWrapper: NSViewRepresentable { if abs(newWidth - lastObservedViewportWidth) > 0.5 { lastObservedViewportWidth = newWidth // Re-center the column by position (no redraw) so it stays smooth during live resize. - if configuration.readingWidth != nil { + // Read readingWidth from the live textView.configuration (a class, captured by + // reference) instead of the struct `configuration` captured by value at + // makeNSView time — the embedder may change readingWidth between updates. + if textView.configuration.readingWidth != nil { textView.centerReadingColumn(forClipWidth: newWidth) } context.coordinator.didEnsureLayoutForCurrentDocument = false @@ -273,8 +319,21 @@ public struct NativeTextViewWrapper: NSViewRepresentable { // back here and re-trigger recalcOverscroll, causing a 149pt height // oscillation after clicks. Compare the CONTAINER (the document view) height // to the viewport — it tracks the viewport for short docs. - guard let container = scrollView.documentView as? NativeTextViewContainer, - abs(container.frame.height - scrollView.contentView.bounds.height) > 1 else { return } + guard let container = scrollView.documentView as? NativeTextViewContainer else { return } + // Read heightBehavior from the live textView.configuration (a class, + // captured by reference) — not the struct `configuration` captured by + // value at makeNSView time. Without this, a runtime .fitsContent→.scrolls + // switch leaves this closure permanently early-returning, so viewport- + // resize-driven recalcOverscroll is skipped → stale overscroll. + if textView.configuration.heightBehavior == .fitsContent { + // In .fitsContent the container is content-tall (not viewport-tall), + // so the container-vs-viewport guard below is always true — which + // would fire recalcOverscroll on every clip-view frame change. Only + // width changes need a re-measure (text re-wraps); height-only + // changes are already handled by the width-change block above. + return + } + guard abs(container.frame.height - scrollView.contentView.bounds.height) > 1 else { return } textView.recalcOverscroll(for: scrollView) scrollView.clampToInsets() } @@ -323,13 +382,26 @@ public struct NativeTextViewWrapper: NSViewRepresentable { context.coordinator.isWritingToolsActive = false } else if wtActive { // WT active on the same node — don't interfere with the session. + // Note: this skips the heightBehavior sync below, so a heightBehavior + // change while Writing Tools is active won't take effect until the + // session ends. WT sessions are transient and height-mode switches + // during one are not a supported use case. return } textView.onPasteImage = onPasteImage textView.setPlaceholder(placeholder) - if nsView.hasVerticalScroller != configuration.scrollers.hasVerticalScroller { - nsView.hasVerticalScroller = configuration.scrollers.hasVerticalScroller + // Sync heightBehavior across all three layers (scroll view, text view, + // coordinator) so a runtime switch fully reconfigures. + let heightBehaviorChanged = textView.configuration.heightBehavior != configuration.heightBehavior + if let clamped = nsView as? ClampedScrollView { + clamped.fitsContent = configuration.heightBehavior == .fitsContent + } + textView.configuration.heightBehavior = configuration.heightBehavior + context.coordinator.configuration.heightBehavior = configuration.heightBehavior + let desiredVerticalScroller = configuration.heightBehavior.wantsVerticalScroller(for: configuration.scrollers) + if nsView.hasVerticalScroller != desiredVerticalScroller { + nsView.hasVerticalScroller = desiredVerticalScroller } if nsView.hasHorizontalScroller != configuration.scrollers.hasHorizontalScroller { nsView.hasHorizontalScroller = configuration.scrollers.hasHorizontalScroller @@ -337,6 +409,13 @@ public struct NativeTextViewWrapper: NSViewRepresentable { if nsView.autohidesScrollers != configuration.scrollers.autohidesScrollers { nsView.autohidesScrollers = configuration.scrollers.autohidesScrollers } + // When heightBehavior changes at runtime, re-measure and re-report so the + // view reconfigures immediately (inflation toggles, overscroll zeroing). + if heightBehaviorChanged { + textView.recalcOverscroll(for: nsView) + (nsView as? ClampedScrollView)?.clampToInsets() + nsView.invalidateIntrinsicContentSize() + } // Reading column centers by POSITION (container subview), so the text inset is constant. let desiredTextInset = NSSize( width: configuration.textInsets.horizontal, @@ -477,6 +556,14 @@ private extension NativeTextViewWrapper { /// Host the embedder's header above the body, inside the container document /// view. The hosted content refreshes on every SwiftUI update; build, /// collapse/expand, and teardown live in `ScrollingHeaderController`. + /// + /// **`.fitsContent` note:** The header's band height is included in the + /// reported content height (via `scrollableContentHeight`), so a static + /// header works correctly. The *collapse-on-scroll* animation is driven by + /// the inner scroll offset, which is always zero in `.fitsContent` (no + /// internal scrolling), so the collapse never triggers. Combining a + /// collapsing header with `.fitsContent` is allowed but the collapse + /// behavior is not meaningful. func reconcileHeader(textView: NSTextView, context: Context) { let coord = context.coordinator guard let container = (textView as? NativeTextView)?.superview as? NativeTextViewContainer else { return } diff --git a/Tests/MarkdownEngineTests/HeightBehaviorTests.swift b/Tests/MarkdownEngineTests/HeightBehaviorTests.swift new file mode 100644 index 0000000..c268667 --- /dev/null +++ b/Tests/MarkdownEngineTests/HeightBehaviorTests.swift @@ -0,0 +1,814 @@ +// +// HeightBehaviorTests.swift +// MarkdownEngineTests +// +// Configuration, inflation gating, overscroll zeroing, intrinsic content +// size, runtime switching, async re-report, readingWidth composition, and +// empty-document minimum height for the .fitsContent height behavior — +// headless (no window). +// +// Known coverage gap: `propagateCaretRevealToEnclosingScroller` (caret +// visibility in a tall .fitsContent block). This requires a live +// nested-scroll-view hierarchy (SwiftUI ScrollView hosting our +// NSScrollView) which cannot be constructed in a headless test. +// Verify manually in a host app. +// + +import AppKit +import Testing +@testable import MarkdownEngine + +// MARK: - Shared test stack + +/// Scroll view + container + text view wired the way `makeNSView` wires them, +/// used by all HeightBehavior test suites. +@MainActor +struct HeightBehaviorStack { + let scrollView: ClampedScrollView + let container: NativeTextViewContainer + let textView: NativeTextView + + init( + viewport: NSSize = NSSize(width: 600, height: 800), + heightBehavior: MarkdownEditorConfiguration.HeightBehavior = .scrolls + ) { + let sv = ClampedScrollView(frame: NSRect(origin: .zero, size: viewport)) + sv.fitsContent = heightBehavior == .fitsContent + let tv = NativeTextView(frame: .zero) + var config = MarkdownEditorConfiguration.default + config.heightBehavior = heightBehavior + tv.configuration = config + tv.overscrollPercent = config.overscroll.percent + tv.maxOverscrollPoints = config.overscroll.maxPoints + tv.minOverscrollPoints = config.overscroll.minPoints + tv.autoresizingMask = [] + let c = NativeTextViewContainer(frame: NSRect(origin: .zero, size: viewport)) + c.autoresizingMask = [.width] + c.textView = tv + tv.frame = NSRect(x: 0, y: 0, width: viewport.width, height: 0) + c.addSubview(tv) + sv.documentView = c + self.scrollView = sv + self.container = c + self.textView = tv + } +} + +// MARK: - Configuration defaults + +@Suite("HeightBehavior configuration") +struct HeightBehaviorDefaultTests { + + @Test func defaultIsScrolls() { + let config = MarkdownEditorConfiguration.default + #expect(config.heightBehavior == .scrolls) + } + + @Test func initWithFitsContent() { + let config = MarkdownEditorConfiguration(heightBehavior: .fitsContent) + #expect(config.heightBehavior == .fitsContent) + } + + @Test func defaultInitExplicitlyScrolls() { + let config = MarkdownEditorConfiguration(heightBehavior: .scrolls) + #expect(config.heightBehavior == .scrolls) + } +} + +// MARK: - Inflation gating + +@MainActor +@Suite("FitsContent inflation gating") +struct FitsContentInflationTests { + + @Test func scrollsInflatesShortDocToViewport() { + let stack = HeightBehaviorStack(heightBehavior: .scrolls) + stack.textView.baseContentHeight = 100 + stack.textView.applyManagedFrameSize(width: 600) + + // Existing behavior: short text view inflates to fill viewport. + #expect(stack.textView.frame.height == 800) + #expect(stack.container.frame.height == 800) + } + + @Test func fitsContentNoInflation() { + let stack = HeightBehaviorStack(heightBehavior: .fitsContent) + stack.textView.baseContentHeight = 100 + stack.textView.applyManagedFrameSize(width: 600) + + // In .fitsContent, the text view is exactly content height, no inflation. + #expect(stack.textView.frame.height == 100) + // Container should also NOT inflate to viewport. + #expect(stack.container.frame.height == 100) + } + + @Test func fitsContentRestackNoInflation() { + let stack = HeightBehaviorStack(heightBehavior: .fitsContent) + stack.textView.baseContentHeight = 200 + stack.textView.applyManagedFrameSize(width: 600) + + // With a header band, the container should still be exactly header + text. + stack.container.headerHeight = 40 + + let expectedTextHeight = stack.textView.frame.height + let expectedContainerHeight = 40 + expectedTextHeight + #expect(stack.container.frame.height == expectedContainerHeight) + } + + @Test func scrollsRestackInflatesToViewport() { + let stack = HeightBehaviorStack(heightBehavior: .scrolls) + stack.textView.baseContentHeight = 100 + stack.textView.applyManagedFrameSize(width: 600) + + // The container inflates to viewport height. + #expect(stack.container.frame.height == 800) + + // Adding a header still keeps the container at viewport height. + stack.container.headerHeight = 40 + #expect(stack.container.frame.height == 800) + } +} + +// MARK: - Overscroll zeroing + +@MainActor +@Suite("FitsContent overscroll") +struct FitsContentOverscrollTests { + + @Test func fitsContentOverscrollIsAlwaysZero() { + let stack = HeightBehaviorStack(heightBehavior: .fitsContent) + // Manually prime a tall content height and run the overscroll policy via + // reapplyOverscrollPolicy (which re-evaluates without re-measuring). + stack.textView.baseContentHeight = 1200 + stack.textView.reapplyOverscrollPolicy(for: stack.scrollView) + + #expect(stack.textView.activeBottomOverscroll == 0) + } + + @Test func scrollsOverscrollIsNonZeroForTallContent() { + let stack = HeightBehaviorStack(heightBehavior: .scrolls) + // Manually prime a tall content height that exceeds the viewport. + stack.textView.baseContentHeight = 1200 + stack.textView.reapplyOverscrollPolicy(for: stack.scrollView) + + #expect(stack.textView.activeBottomOverscroll > 0) + } + + @Test func fitsContentRecalcOverscrollStillMeasuresHeight() { + // Verify that recalcOverscroll still updates baseContentHeight in + // .fitsContent mode (critical: sizeThatFits / intrinsicContentSize + // report this value). The measuredBaseContentHeight in a headless + // text view returns the minimum (one line height), but the important + // assertion is that the call runs and assigns a value rather than + // short-circuiting the measurement. + let stack = HeightBehaviorStack(heightBehavior: .fitsContent) + stack.textView.baseContentHeight = 0 + stack.textView.recalcOverscroll(for: stack.scrollView) + + // baseContentHeight must be set to the measured value (> 0 for even + // an empty document in TextKit-2, which returns at least one line). + #expect(stack.textView.baseContentHeight > 0) + // Overscroll must still be zero in .fitsContent. + #expect(stack.textView.activeBottomOverscroll == 0) + } +} + +// MARK: - ClampedScrollView intrinsic content size + +@MainActor +@Suite("ClampedScrollView fitsContent") +struct ClampedScrollViewFitsContentTests { + + @Test func intrinsicContentSizeReportsHeightWhenFitsContent() { + let stack = HeightBehaviorStack(heightBehavior: .fitsContent) + stack.textView.baseContentHeight = 350 + stack.textView.applyManagedFrameSize(width: 600) + + let intrinsic = stack.scrollView.intrinsicContentSize + #expect(intrinsic.width == NSView.noIntrinsicMetric) + #expect(intrinsic.height == stack.container.scrollableContentHeight) + } + + @Test func intrinsicContentSizeDefaultsToNoMetricWhenScrolls() { + let stack = HeightBehaviorStack(heightBehavior: .scrolls) + stack.textView.baseContentHeight = 350 + stack.textView.applyManagedFrameSize(width: 600) + + let intrinsic = stack.scrollView.intrinsicContentSize + // Default NSScrollView returns noIntrinsicMetric for both dimensions. + #expect(intrinsic.width == NSView.noIntrinsicMetric) + #expect(intrinsic.height == NSView.noIntrinsicMetric) + } + + @Test func intrinsicContentSizeIncludesHeaderHeight() { + let stack = HeightBehaviorStack(heightBehavior: .fitsContent) + stack.textView.baseContentHeight = 300 + stack.textView.applyManagedFrameSize(width: 600) + stack.container.headerHeight = 50 + + let intrinsic = stack.scrollView.intrinsicContentSize + // scrollableContentHeight = headerHeight + textView.scrollableContentHeight + #expect(intrinsic.height == stack.container.scrollableContentHeight) + #expect(intrinsic.height == 50 + stack.textView.scrollableContentHeight) + } +} + +// MARK: - Runtime heightBehavior switch + +@MainActor +@Suite("Runtime heightBehavior switch") +struct RuntimeHeightBehaviorSwitchTests { + + @Test func switchFromScrollsToFitsContentRemovesInflation() { + // Start in .scrolls — short content inflated to viewport. + let stack = HeightBehaviorStack(heightBehavior: .scrolls) + stack.textView.baseContentHeight = 100 + stack.textView.applyManagedFrameSize(width: 600) + #expect(stack.textView.frame.height == 800) + #expect(stack.container.frame.height == 800) + + // Switch to .fitsContent at runtime. + var newConfig = stack.textView.configuration + newConfig.heightBehavior = .fitsContent + stack.textView.configuration = newConfig + stack.scrollView.fitsContent = true + // Use reapplyOverscrollPolicy (not recalcOverscroll) to avoid + // TextKit-2 re-measuring the content height in a headless test, + // then applyManagedFrameSize to reconfigure the frame. + stack.textView.reapplyOverscrollPolicy(for: stack.scrollView) + stack.textView.applyManagedFrameSize(width: 600) + + // Inflation removed: text view and container match content height. + #expect(stack.textView.frame.height == 100) + #expect(stack.container.frame.height == 100) + } + + @Test func switchFromFitsContentToScrollsRestoresInflation() { + // Start in .fitsContent — exact content height. + let stack = HeightBehaviorStack(heightBehavior: .fitsContent) + stack.textView.baseContentHeight = 100 + stack.textView.applyManagedFrameSize(width: 600) + #expect(stack.textView.frame.height == 100) + + // Switch to .scrolls at runtime. + var newConfig = stack.textView.configuration + newConfig.heightBehavior = .scrolls + stack.textView.configuration = newConfig + stack.scrollView.fitsContent = false + stack.textView.recalcOverscroll(for: stack.scrollView) + + // Inflation restored: text view fills the viewport. + #expect(stack.textView.frame.height == 800) + #expect(stack.container.frame.height == 800) + } + + @Test func switchToFitsContentZerosOverscroll() { + // Start in .scrolls with tall content that has overscroll. + let stack = HeightBehaviorStack(heightBehavior: .scrolls) + stack.textView.baseContentHeight = 1200 + stack.textView.reapplyOverscrollPolicy(for: stack.scrollView) + #expect(stack.textView.activeBottomOverscroll > 0) + + // Switch to .fitsContent. + var newConfig = stack.textView.configuration + newConfig.heightBehavior = .fitsContent + stack.textView.configuration = newConfig + stack.scrollView.fitsContent = true + stack.textView.reapplyOverscrollPolicy(for: stack.scrollView) + + #expect(stack.textView.activeBottomOverscroll == 0) + } + + @Test func switchToScrollsRestoresOverscroll() { + // Start in .fitsContent — overscroll is zero. + let stack = HeightBehaviorStack(heightBehavior: .fitsContent) + stack.textView.baseContentHeight = 1200 + stack.textView.reapplyOverscrollPolicy(for: stack.scrollView) + #expect(stack.textView.activeBottomOverscroll == 0) + + // Switch to .scrolls. + var newConfig = stack.textView.configuration + newConfig.heightBehavior = .scrolls + stack.textView.configuration = newConfig + stack.scrollView.fitsContent = false + stack.textView.reapplyOverscrollPolicy(for: stack.scrollView) + + #expect(stack.textView.activeBottomOverscroll > 0) + } + + @Test func switchReconfiguresIntrinsicContentSize() { + let stack = HeightBehaviorStack(heightBehavior: .scrolls) + stack.textView.baseContentHeight = 400 + stack.textView.applyManagedFrameSize(width: 600) + + // In .scrolls, intrinsicContentSize.height is noIntrinsicMetric. + #expect(stack.scrollView.intrinsicContentSize.height == NSView.noIntrinsicMetric) + + // Switch to .fitsContent. + var newConfig = stack.textView.configuration + newConfig.heightBehavior = .fitsContent + stack.textView.configuration = newConfig + stack.scrollView.fitsContent = true + stack.textView.recalcOverscroll(for: stack.scrollView) + + // Now intrinsicContentSize should report actual height. + #expect(stack.scrollView.intrinsicContentSize.height == stack.container.scrollableContentHeight) + } +} + +// MARK: - Reading width + fitsContent + +@MainActor +@Suite("ReadingWidth + fitsContent") +struct ReadingWidthFitsContentTests { + + @Test func readingWidthPreservedNoInflation() { + let viewport = NSSize(width: 800, height: 600) + let sv = ClampedScrollView(frame: NSRect(origin: .zero, size: viewport)) + sv.fitsContent = true + let tv = NativeTextView(frame: .zero) + var config = MarkdownEditorConfiguration.default + config.heightBehavior = .fitsContent + config.readingWidth = 400 + tv.configuration = config + tv.autoresizingMask = [] + let c = NativeTextViewContainer(frame: NSRect(origin: .zero, size: viewport)) + c.autoresizingMask = [.width] + c.textView = tv + let columnWidth = tv.readingColumnWidth + tv.frame = NSRect(x: 0, y: 0, width: columnWidth, height: 0) + c.addSubview(tv) + sv.documentView = c + + tv.baseContentHeight = 200 + tv.applyManagedFrameSize(width: columnWidth) + + // Column keeps its fixed width. + #expect(tv.frame.width == columnWidth) + // Height is exact content (no viewport inflation). + #expect(tv.frame.height == 200) + // Container is also content-tall, not viewport-inflated. + #expect(c.frame.height == 200) + } +} + +// MARK: - Empty-document minimum height + +@MainActor +@Suite("FitsContent empty-document minimum height") +struct FitsContentEmptyDocMinHeightTests { + + @Test func emptyDocHasPositiveHeight() { + let stack = HeightBehaviorStack(heightBehavior: .fitsContent) + // A fresh text view with no text inserted — recalcOverscroll measures + // the TextKit-2 content height, which returns at least one line height. + stack.textView.recalcOverscroll(for: stack.scrollView) + + // baseContentHeight must be positive (at least one body line). + #expect(stack.textView.baseContentHeight > 0) + // The frame must reflect that minimum. + #expect(stack.textView.frame.height > 0) + // scrollableContentHeight = baseContentHeight (no overscroll). + #expect(stack.textView.scrollableContentHeight == stack.textView.baseContentHeight) + } +} + +// MARK: - Header + fitsContent height composition + +@MainActor +@Suite("Header + fitsContent height") +struct HeaderFitsContentTests { + + @Test func staticHeaderIncludedInScrollableContentHeight() { + let stack = HeightBehaviorStack(heightBehavior: .fitsContent) + stack.textView.baseContentHeight = 300 + stack.textView.applyManagedFrameSize(width: 600) + stack.container.headerHeight = 60 + + // scrollableContentHeight = header + text view's scrollableContentHeight + let expectedTotal: CGFloat = 60 + 300 + #expect(stack.container.scrollableContentHeight == expectedTotal) + // Container frame matches exactly (no viewport inflation). + #expect(stack.container.frame.height == expectedTotal) + // Text view sits below the header. + #expect(stack.textView.frame.origin.y == 60) + } + + @Test func headerChangeReReportsIntrinsicSize() { + let stack = HeightBehaviorStack(heightBehavior: .fitsContent) + stack.textView.baseContentHeight = 300 + stack.textView.applyManagedFrameSize(width: 600) + + let beforeHeader = stack.scrollView.intrinsicContentSize.height + stack.container.headerHeight = 80 + let afterHeader = stack.scrollView.intrinsicContentSize.height + + // Adding a header should increase the reported height by the header band. + #expect(afterHeader == beforeHeader + 80) + } +} + +// MARK: - Scroll-wheel forwarding + +@MainActor +@Suite("FitsContent scroll-wheel forwarding") +struct ScrollWheelForwardingTests { + + /// Minimal NSView that records whether it received a scrollWheel event. + final class ScrollWheelSpy: NSView { + var receivedScrollWheel = false + override func scrollWheel(with event: NSEvent) { + receivedScrollWheel = true + } + } + + @Test func fitsContentForwardsScrollWheelToNextResponder() { + let stack = HeightBehaviorStack(heightBehavior: .fitsContent) + let spy = ScrollWheelSpy(frame: .zero) + // Wire the spy as the scroll view's nextResponder. + stack.scrollView.nextResponder = spy + + // Create a synthetic scroll event. CGEvent is used because + // NSEvent(scrollWheel:) does not exist as a public initializer. + guard let cgEvent = CGEvent( + scrollWheelEvent2Source: nil, + units: .pixel, + wheelCount: 1, + wheel1: 10, + wheel2: 0, + wheel3: 0 + ) else { return } + guard let nsEvent = NSEvent(cgEvent: cgEvent) else { return } + stack.scrollView.scrollWheel(with: nsEvent) + + #expect(spy.receivedScrollWheel == true) + } + + @Test func scrollsDoesNotForwardScrollWheel() { + let stack = HeightBehaviorStack(heightBehavior: .scrolls) + let spy = ScrollWheelSpy(frame: .zero) + stack.scrollView.nextResponder = spy + + guard let cgEvent = CGEvent( + scrollWheelEvent2Source: nil, + units: .pixel, + wheelCount: 1, + wheel1: 10, + wheel2: 0, + wheel3: 0 + ) else { return } + guard let nsEvent = NSEvent(cgEvent: cgEvent) else { return } + stack.scrollView.scrollWheel(with: nsEvent) + + // In .scrolls mode, scrollWheel is handled by super (NSScrollView), + // not forwarded to nextResponder. + #expect(spy.receivedScrollWheel == false) + } +} + +// MARK: - Async height-change re-report + +@MainActor +@Suite("FitsContent async height-change re-report") +struct AsyncHeightChangeTests { + + @Test func contentHeightChangeUpdatesFrame() { + // Simulate an async content height change (e.g. image/LaTeX finishing layout) + // by mutating baseContentHeight and calling applyManagedFrameSize. + let stack = HeightBehaviorStack(heightBehavior: .fitsContent) + stack.textView.baseContentHeight = 200 + stack.textView.applyManagedFrameSize(width: 600) + #expect(stack.textView.frame.height == 200) + #expect(stack.container.frame.height == 200) + + // Async height change: content grows. + stack.textView.baseContentHeight = 450 + stack.textView.applyManagedFrameSize(width: 600) + + #expect(stack.textView.frame.height == 450) + #expect(stack.container.frame.height == 450) + // intrinsicContentSize reflects the new height. + #expect(stack.scrollView.intrinsicContentSize.height == 450) + } + + @Test func contentHeightChangePreservesExactHeight() { + // After an async height change, no viewport inflation occurs. + let stack = HeightBehaviorStack( + viewport: NSSize(width: 600, height: 800), + heightBehavior: .fitsContent + ) + stack.textView.baseContentHeight = 300 + stack.textView.applyManagedFrameSize(width: 600) + + // Grow past the viewport: still exact content height, not clamped. + stack.textView.baseContentHeight = 1200 + stack.textView.applyManagedFrameSize(width: 600) + + #expect(stack.textView.frame.height == 1200) + #expect(stack.container.frame.height == 1200) + } + + @Test func heightShrinkUpdatesFrame() { + // Content can also shrink (e.g. image removed, text deleted). + let stack = HeightBehaviorStack(heightBehavior: .fitsContent) + stack.textView.baseContentHeight = 600 + stack.textView.applyManagedFrameSize(width: 600) + #expect(stack.textView.frame.height == 600) + + stack.textView.baseContentHeight = 150 + stack.textView.applyManagedFrameSize(width: 600) + + #expect(stack.textView.frame.height == 150) + #expect(stack.container.frame.height == 150) + #expect(stack.scrollView.intrinsicContentSize.height == 150) + } + + @Test func heightChangeInScrollsModeInflates() { + // Contrast: in .scrolls mode, short content inflates to viewport. + let stack = HeightBehaviorStack( + viewport: NSSize(width: 600, height: 800), + heightBehavior: .scrolls + ) + stack.textView.baseContentHeight = 200 + stack.textView.applyManagedFrameSize(width: 600) + #expect(stack.textView.frame.height == 800) + + // Growing past viewport: frame matches content. + stack.textView.baseContentHeight = 1200 + stack.textView.applyManagedFrameSize(width: 600) + #expect(stack.textView.frame.height == 1200) + + // Shrinking below viewport: inflates back to viewport. + stack.textView.baseContentHeight = 300 + stack.textView.applyManagedFrameSize(width: 600) + #expect(stack.textView.frame.height == 800) + } + + @Test func asyncChangeWithHeaderUpdatesTotal() { + // Async height change with a header band present. + let stack = HeightBehaviorStack(heightBehavior: .fitsContent) + stack.textView.baseContentHeight = 200 + stack.textView.applyManagedFrameSize(width: 600) + stack.container.headerHeight = 60 + + // Simulate async growth. + stack.textView.baseContentHeight = 400 + stack.textView.applyManagedFrameSize(width: 600) + + let expectedTotal: CGFloat = 60 + 400 + #expect(stack.textView.frame.height == 400) + #expect(stack.container.frame.height == expectedTotal) + #expect(stack.scrollView.intrinsicContentSize.height == expectedTotal) + } +} + +// MARK: - Per-keystroke recalc chain + +@MainActor +@Suite("FitsContent per-keystroke recalc chain") +struct FitsContentRecalcChainTests { + + @Test func recalcUpdatesFrameAndContainer() { + // The per-keystroke chain: recalcOverscroll measures content, + // calls applyManagedFrameSize, which sizes the frame and + // triggers container restack. + let stack = HeightBehaviorStack(heightBehavior: .fitsContent) + + // Prime with an initial recalc (empty doc → one line height). + stack.textView.recalcOverscroll(for: stack.scrollView) + let initialHeight = stack.textView.frame.height + #expect(initialHeight > 0) + + // The container matches the text view (no header, no inflation). + #expect(stack.container.frame.height == initialHeight) + // Overscroll stays zero. + #expect(stack.textView.activeBottomOverscroll == 0) + } + + @Test func recalcAfterHeightChangeUpdatesIntrinsicSize() { + // After a height change driven through recalcOverscroll, the + // scroll view's intrinsicContentSize must reflect the new value. + let stack = HeightBehaviorStack(heightBehavior: .fitsContent) + stack.textView.recalcOverscroll(for: stack.scrollView) + let initial = stack.scrollView.intrinsicContentSize.height + + // Manually set a taller base and re-run the recalc chain. + stack.textView.baseContentHeight = 500 + stack.textView.applyManagedFrameSize(width: 600) + + let updated = stack.scrollView.intrinsicContentSize.height + #expect(updated == 500) + #expect(updated > initial) + } +} + +// MARK: - Height change after runtime switch + +@MainActor +@Suite("Height change after runtime switch") +struct HeightChangeAfterRuntimeSwitchTests { + + @Test func heightChangeAfterSwitchToFitsContent() { + // Start in .scrolls, switch to .fitsContent, then change height. + let stack = HeightBehaviorStack( + viewport: NSSize(width: 600, height: 800), + heightBehavior: .scrolls + ) + stack.textView.baseContentHeight = 200 + stack.textView.applyManagedFrameSize(width: 600) + #expect(stack.textView.frame.height == 800) // inflated + + // Switch to .fitsContent. + var config = stack.textView.configuration + config.heightBehavior = .fitsContent + stack.textView.configuration = config + stack.scrollView.fitsContent = true + stack.textView.reapplyOverscrollPolicy(for: stack.scrollView) + stack.textView.applyManagedFrameSize(width: 600) + #expect(stack.textView.frame.height == 200) // deflated + + // Now simulate an async height change. + stack.textView.baseContentHeight = 350 + stack.textView.applyManagedFrameSize(width: 600) + + // Must be exact content height, no inflation. + #expect(stack.textView.frame.height == 350) + #expect(stack.container.frame.height == 350) + #expect(stack.scrollView.intrinsicContentSize.height == 350) + } + + @Test func heightChangeAfterSwitchToScrolls() { + // Start in .fitsContent, switch to .scrolls, then change height. + let stack = HeightBehaviorStack( + viewport: NSSize(width: 600, height: 800), + heightBehavior: .fitsContent + ) + stack.textView.baseContentHeight = 200 + stack.textView.applyManagedFrameSize(width: 600) + #expect(stack.textView.frame.height == 200) + + // Switch to .scrolls. + var config = stack.textView.configuration + config.heightBehavior = .scrolls + stack.textView.configuration = config + stack.scrollView.fitsContent = false + stack.textView.recalcOverscroll(for: stack.scrollView) + #expect(stack.textView.frame.height == 800) // inflated + + // Async height change: still below viewport → stays inflated. + stack.textView.baseContentHeight = 400 + stack.textView.applyManagedFrameSize(width: 600) + #expect(stack.textView.frame.height == 800) + + // intrinsicContentSize should revert to noIntrinsicMetric. + #expect(stack.scrollView.intrinsicContentSize.height == NSView.noIntrinsicMetric) + } +} + +// MARK: - Runtime switch scroller visibility + +@MainActor +@Suite("Runtime switch scroller visibility") +struct RuntimeSwitchScrollerVisibilityTests { + + // All tests call the shared production function + // `HeightBehavior.wantsVerticalScroller(for:)` — the same function + // used by `makeNSView` and `updateNSView` to set + // `scrollView.hasVerticalScroller`. No duplicated logic. + + @Test func fitsContentAlwaysDisablesVerticalScroller() { + // Even when the scrollers policy has vertical scroller = true, + // .fitsContent overrides it to false (nothing to scroll). + let policy = ScrollersPolicy(hasVerticalScroller: true) + let result = MarkdownEditorConfiguration.HeightBehavior.fitsContent + .wantsVerticalScroller(for: policy) + #expect(result == false) + } + + @Test func scrollsRespectsScrollersPolicy() { + // In .scrolls, the scroller follows the policy. + let policyOn = ScrollersPolicy(hasVerticalScroller: true) + #expect( + MarkdownEditorConfiguration.HeightBehavior.scrolls + .wantsVerticalScroller(for: policyOn) == true + ) + + let policyOff = ScrollersPolicy(hasVerticalScroller: false) + #expect( + MarkdownEditorConfiguration.HeightBehavior.scrolls + .wantsVerticalScroller(for: policyOff) == false + ) + } + +} + +// MARK: - Full runtime reconfiguration chain + +@MainActor +@Suite("Full runtime reconfiguration") +struct FullRuntimeReconfigurationTests { + + @Test func switchReconfiguresAllThreeLayers() { + // Verify that a runtime switch syncs the heightBehavior across + // scroll view (fitsContent flag), text view (configuration), + // and that intrinsicContentSize reflects the new mode. + let stack = HeightBehaviorStack( + viewport: NSSize(width: 600, height: 800), + heightBehavior: .scrolls + ) + stack.textView.baseContentHeight = 300 + stack.textView.applyManagedFrameSize(width: 600) + + // Verify initial state: .scrolls. + #expect(stack.scrollView.fitsContent == false) + #expect(stack.textView.configuration.heightBehavior == .scrolls) + #expect(stack.scrollView.intrinsicContentSize.height == NSView.noIntrinsicMetric) + + // Switch to .fitsContent, mimicking updateNSView. + var newConfig = stack.textView.configuration + newConfig.heightBehavior = .fitsContent + stack.textView.configuration = newConfig + stack.scrollView.fitsContent = true + stack.textView.reapplyOverscrollPolicy(for: stack.scrollView) + stack.textView.applyManagedFrameSize(width: 600) + stack.scrollView.invalidateIntrinsicContentSize() + + // All three layers should reflect .fitsContent. + #expect(stack.scrollView.fitsContent == true) + #expect(stack.textView.configuration.heightBehavior == .fitsContent) + #expect(stack.textView.activeBottomOverscroll == 0) + #expect(stack.textView.frame.height == 300) + #expect(stack.container.frame.height == 300) + #expect(stack.scrollView.intrinsicContentSize.height == 300) + } + + @Test func switchPreservesContentHeight() { + // Content height should survive a round-trip switch: + // .scrolls → .fitsContent → .scrolls + // Uses reapplyOverscrollPolicy (not recalcOverscroll) to avoid + // TextKit-2 re-measuring in a headless test, which would overwrite + // the primed baseContentHeight. + // + // Use a short content (100pt) that stays below the overscroll + // activation threshold so the .scrolls frame inflates to exactly + // the viewport height with no added overscroll. + let stack = HeightBehaviorStack( + viewport: NSSize(width: 600, height: 800), + heightBehavior: .scrolls + ) + stack.textView.baseContentHeight = 100 + stack.textView.applyManagedFrameSize(width: 600) + let originalBaseHeight = stack.textView.baseContentHeight + + // → .fitsContent + var fc = stack.textView.configuration + fc.heightBehavior = .fitsContent + stack.textView.configuration = fc + stack.scrollView.fitsContent = true + stack.textView.reapplyOverscrollPolicy(for: stack.scrollView) + stack.textView.applyManagedFrameSize(width: 600) + #expect(stack.textView.baseContentHeight == originalBaseHeight) + #expect(stack.textView.frame.height == 100) + + // → back to .scrolls + var sc = stack.textView.configuration + sc.heightBehavior = .scrolls + stack.textView.configuration = sc + stack.scrollView.fitsContent = false + stack.textView.reapplyOverscrollPolicy(for: stack.scrollView) + stack.textView.applyManagedFrameSize(width: 600) + #expect(stack.textView.baseContentHeight == originalBaseHeight) + // Frame is inflated to viewport since content (100) < viewport (800) + // and content is below the overscroll activation threshold. + #expect(stack.textView.frame.height == 800) + } + + @Test func switchWithHeaderPreservesTotal() { + // With a header band, switching should preserve the total + // scrollableContentHeight composition. + let stack = HeightBehaviorStack( + viewport: NSSize(width: 600, height: 800), + heightBehavior: .fitsContent + ) + stack.textView.baseContentHeight = 250 + stack.textView.applyManagedFrameSize(width: 600) + stack.container.headerHeight = 50 + + let expectedTotal: CGFloat = 50 + 250 + #expect(stack.container.scrollableContentHeight == expectedTotal) + + // Switch to .scrolls. + var sc = stack.textView.configuration + sc.heightBehavior = .scrolls + stack.textView.configuration = sc + stack.scrollView.fitsContent = false + stack.textView.reapplyOverscrollPolicy(for: stack.scrollView) + stack.textView.applyManagedFrameSize(width: 600) + + // scrollableContentHeight still composes header + content. + // (The container inflates to viewport, but scrollableContentHeight + // is the real content height, used by clampToInsets.) + let scrollsTotal: CGFloat = 50 + stack.textView.scrollableContentHeight + #expect(stack.container.scrollableContentHeight == scrollsTotal) + } +}