diff --git a/Sources/MarkdownEngine/TextView/ClampedScrollView.swift b/Sources/MarkdownEngine/TextView/ClampedScrollView.swift index 36662b7..2c21af3 100644 --- a/Sources/MarkdownEngine/TextView/ClampedScrollView.swift +++ b/Sources/MarkdownEngine/TextView/ClampedScrollView.swift @@ -38,10 +38,24 @@ final class ClampedScrollView: NSScrollView { // Use the real content height (not the inflated frame) so small // documents can't scroll past their actual content. The document view is // always a `NativeTextViewContainer` (header band + text column). - let realHeight = (doc as? NativeTextViewContainer)?.scrollableContentHeight - ?? doc.bounds.height - let maxY = max(minY, realHeight - contentView.bounds.height) + let container = doc as? NativeTextViewContainer + var realHeight = container?.scrollableContentHeight ?? doc.bounds.height let b = contentView.bounds + + // `scrollableContentHeight` comes from a cached TextKit-2 measurement that can + // under-measure. A continuous trackpad refreshes it mid-gesture, but a discrete + // device (mouse wheel, Ploopy trackball) sends one event with no relayout — so a + // stale-small height clamps the tick straight back = "scroll doesn't work". When + // a clamp-back is imminent, force one fresh full-layout re-measure first. This is + // self-limiting (only at the bottom) and still clamps to the real content height. + if let textView = container?.textView, + b.origin.y > realHeight - b.height { + textView.pendingFullLayoutMeasure = true + textView.recalcOverscroll(for: self) + realHeight = container?.scrollableContentHeight ?? doc.bounds.height + } + + let maxY = max(minY, realHeight - contentView.bounds.height) let clampedY = min(max(b.origin.y, minY), maxY) if clampedY != b.origin.y { contentView.scroll(to: NSPoint(x: b.origin.x, y: clampedY))