From 9e059368063f53c5b1e8ccec8d84a2ed3b204efc Mon Sep 17 00:00:00 2001 From: Yishen Tu Date: Wed, 12 Aug 2026 21:54:56 +0800 Subject: [PATCH] fix: render tables synchronously during live resize Apply width-dependent table styling before each physical resize callback returns, while coalescing ordinary width writes. Track fractional geometry, trailing hosted layout propagation, document height, and wide-table overlays with real AppKit regression coverage. --- CHANGELOG.md | 4 + .../Renderer/WideTableOverlay.swift | 112 ++- .../Styling/MarkdownStyler+Tables.swift | 28 +- .../Styling/MarkdownStyler.swift | 123 ++- .../Styling/TextStylingService.swift | 70 +- .../TextView/ClampedScrollView.swift | 41 + .../NativeTextViewCoordinator+Restyling.swift | 71 +- .../NativeTextViewCoordinator.swift | 3 + .../NativeTextView+FrameAndOverscroll.swift | 102 +- .../NativeTextView/NativeTextView.swift | 2 + .../TextView/NativeTextViewContainer.swift | 2 +- .../TableImageCacheTests.swift | 35 + .../TableWidthChangeRestyleTests.swift | 880 +++++++++++++++++- 13 files changed, 1345 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41bf5727..f4e562c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 highlight and collided with the number drawn over it. The marker's caret-crossing restyle signal went with the reveal. +### Fixed +- Rendered tables now follow every live editor-width change, including + fractional widths, and settle at the final width when window resizing ends. + ### Performance - Scoped restyles inside a contiguous list parse and style only intersecting items instead of rebuilding the whole list block. Marker, indentation, diff --git a/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift b/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift index fabd480c..5095706d 100644 --- a/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift +++ b/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift @@ -167,16 +167,28 @@ final class WideTableImageView: NSImageView { extension NativeTextView { - /// Coalesce overlay updates to one per runloop tick (resize fires bursts); first run is sync to avoid a load flash. - func updateWideTableOverlays() { - if wideTableOverlays.isEmpty { - performWideTableOverlayUpdate() + /// Coalesce overlay updates to one per runloop tick (resize fires bursts). + /// The first run is synchronous to avoid a load flash. + /// Width-change restyles can reconcile immediately so the visible overlay + /// never trails the storage attributes by one resize turn. + func updateWideTableOverlays( + immediately: Bool = false, + layoutAlreadySettled: Bool = false, + knownWideTableAnchorRanges: [NSRange]? = nil + ) { + if immediately || wideTableOverlays.isEmpty { + pendingWideTableOverlayUpdate = false + performWideTableOverlayUpdate( + layoutAlreadySettled: layoutAlreadySettled, + knownWideTableAnchorRanges: knownWideTableAnchorRanges + ) return } if pendingWideTableOverlayUpdate { return } pendingWideTableOverlayUpdate = true - DispatchQueue.main.async { [weak self] in + RunLoop.main.perform(inModes: [.default, .eventTracking]) { [weak self] in guard let self else { return } + guard self.pendingWideTableOverlayUpdate else { return } self.pendingWideTableOverlayUpdate = false self.performWideTableOverlayUpdate() } @@ -211,12 +223,14 @@ extension NativeTextView { } /// Walk storage; create / position / destroy overlays to match attrs. - func performWideTableOverlayUpdate() { + func performWideTableOverlayUpdate( + layoutAlreadySettled: Bool = false, + knownWideTableAnchorRanges: [NSRange]? = nil + ) { guard let storage = textStorage, let bridge = layoutBridge, let container = bridge.firstTextContainer, - let tlm = textLayoutManager, - let tcs = tlm.textContentManager as? NSTextContentStorage else { + let tlm = textLayoutManager else { removeAllWideTableOverlays() return } @@ -229,47 +243,71 @@ extension NativeTextView { let host: NSView = breakout ? (superview ?? self) : self let viewWidth = host.bounds.width - var seenSourceIDs: Set = [] - let fullRange = NSRange(location: 0, length: storage.length) - - // Cheap presence-check first: skip the full-document layout pass when - // the doc has no wide tables. enumerateAttribute stops on first hit — - // but a MISS walks every attribute run in the document (scheduled - // after each restyle), so stamp it when it gets slow. - let presenceT0 = DispatchTime.now().uptimeNanoseconds - var hasAnyWideTable = false - storage.enumerateAttribute(.scrollableBlockSourceID, in: fullRange, options: []) { value, _, stop in - if value is Int { hasAnyWideTable = true; stop.pointee = true } - } - let presenceMs = Double(DispatchTime.now().uptimeNanoseconds - presenceT0) / 1_000_000 - if presenceMs > 0.3 { - PerfTrace.stamp("wideTableOverlay.presenceScan", presenceMs, "wide=\(hasAnyWideTable ? 1 : 0) docLen=\(storage.length)") + let anchorRanges: [NSRange] + if let knownWideTableAnchorRanges { + anchorRanges = knownWideTableAnchorRanges + } else { + // Ordinary edits do not carry table results, so discover anchors + // once. Width restyles pass their exact new anchor set and avoid + // this full-storage walk entirely. + let scanStarted = DispatchTime.now().uptimeNanoseconds + var discovered: [NSRange] = [] + let fullRange = NSRange(location: 0, length: storage.length) + storage.enumerateAttribute( + .scrollableBlockSourceID, + in: fullRange, + options: [] + ) { value, attrRange, _ in + if value is Int { discovered.append(attrRange) } + } + anchorRanges = discovered + let scanMs = Double( + DispatchTime.now().uptimeNanoseconds - scanStarted + ) / 1_000_000 + if scanMs > 0.3 { + PerfTrace.stamp( + "wideTableOverlay.anchorScan", + scanMs, + "wide=\(anchorRanges.isEmpty ? 0 : 1) docLen=\(storage.length)" + ) + } } - guard hasAnyWideTable else { + guard !anchorRanges.isEmpty else { removeAllWideTableOverlays() return } + var seenSourceIDs: Set = [] + // Settle layout before measuring — stale fragments would yield wrong anchor Ys. let overlayT0 = DispatchTime.now().uptimeNanoseconds - tlm.ensureLayout(for: tlm.documentRange) + if !layoutAlreadySettled { + tlm.ensureLayout(for: tlm.documentRange) + } PerfTrace.stamp("wideTableOverlay.ensureLayout(fullDoc)", Double(DispatchTime.now().uptimeNanoseconds - overlayT0) / 1_000_000, - "docLen=\(storage.length)") - - storage.enumerateAttribute(.scrollableBlockSourceID, in: fullRange, options: []) { value, attrRange, _ in - guard let sourceID = value as? Int, - let image = storage.attribute(.latexImage, at: attrRange.location, effectiveRange: nil) as? NSImage else { return } + "settled=\(layoutAlreadySettled ? 1 : 0) docLen=\(storage.length)") + + for attrRange in anchorRanges { + guard attrRange.location != NSNotFound, + attrRange.location >= 0, + attrRange.length > 0, + attrRange.length <= storage.length, + attrRange.location <= storage.length - attrRange.length, + let sourceID = storage.attribute( + .scrollableBlockSourceID, + at: attrRange.location, + effectiveRange: nil + ) as? Int, + let image = storage.attribute( + .latexImage, + at: attrRange.location, + effectiveRange: nil + ) as? NSImage else { continue } seenSourceIDs.insert(sourceID) - if let start = tcs.location(tcs.documentRange.location, offsetBy: attrRange.location), - let end = tcs.location(start, offsetBy: attrRange.length), - let textRange = NSTextRange(location: start, end: end) { - tlm.ensureLayout(for: textRange) - } - let anchorRect = bridge.boundingRect(forCharacterRange: attrRange, in: container) - guard !anchorRect.isEmpty else { return } + guard !anchorRect.isEmpty else { continue } let totalHeight = (storage.attribute(.scrollableBlockTotalHeight, at: attrRange.location, effectiveRange: nil) as? CGFloat) ?? image.size.height // In breakout the overlay lives in the container, so add the column's X diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index 1f123530..cb424220 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -141,7 +141,11 @@ extension MarkdownStyler { appearance: NSAppearance, availableWidth: CGFloat ) -> (image: NSImage, rendered: Bool) { - let widthKey = Int(availableWidth.rounded()) + // Rendering consumes the exact point width. Key it losslessly as well: + // fractional SwiftUI/split-view widths can differ by more than 0.5 pt + // while rounding to the same integer, which would otherwise reuse a + // stale image with the wrong wrapping, height, or right inset. + let widthKey = Double(availableWidth).bitPattern // The extension registry is part of the key: `==x==` in a cell renders // highlighted under one config and literal under another — those must // never share a cached image. @@ -249,7 +253,14 @@ extension MarkdownStyler { if rendered { renderedCount += 1 } let imageBounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) // Wide tables → scrollable mode (NSScrollView overlay); narrow → collapsed. - let isWide = image.size.width > containerWidth + 0.5 + // Geometry is fractional in split-view and SwiftUI layouts. Use + // only a floating-point noise allowance here; a real sub-point + // overflow still needs horizontal scrolling. + let widthEpsilon = max( + image.size.width.ulp, + containerWidth.ulp + ) * 8 + let isWide = image.size.width - containerWidth > widthEpsilon let computedSourceID = stableTableSourceID( for: source, occurrenceIndex: occurrenceIndex @@ -562,7 +573,7 @@ extension MarkdownStyler { let extra = contentAvailable - sumMin let totalStretch = sumMax - sumMin columnWidths = zip(minWidths, maxWidths).map { mn, mx in - mn + ((mx - mn) / totalStretch * extra).rounded(.down) + mn + (mx - mn) / totalStretch * extra } } } @@ -692,6 +703,17 @@ extension MarkdownStyler { /// Container width with fallback chain for "styler runs before layout" case. static func effectiveContainerWidth(for ctx: StylingContext) -> CGFloat { if let container = ctx.layoutBridge?.firstTextContainer { + // During SwiftUI-hosted window resizing, NSTextView bounds can be + // updated before a width-tracking NSTextContainer publishes its + // derived size. The view is the width owner in this mode, so use + // its live geometry instead of rerasterizing tables at a stale + // container width. Fixed reading columns do not track the view and + // continue to use their explicit container width below. + if container.widthTracksTextView, let textView = container.textView { + let inset = textView.textContainerInset + let usable = textView.bounds.width - inset.width * 2 + if usable.isFinite, usable > 0 { return usable } + } let raw = container.size.width if raw.isFinite, raw > 0, raw < 100_000 { return raw } if let textView = container.textView { diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift index bf246be4..065e0413 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift @@ -209,37 +209,17 @@ enum MarkdownStyler { scopedRanges: [NSRange]? = nil, configuration: MarkdownEditorConfiguration = .default ) -> [StyledRange] { - let tokens = precomputedTokens ?? MarkdownTokenizer.parseTokensViaAST(in: text, registry: configuration.extensionRegistry) - let nsText = text as NSString - let scopeBounds: (lo: Int, hi: Int)? = scopedRanges.flatMap { ranges in - let valid = ranges.filter { $0.location != NSNotFound && $0.length > 0 } - guard let lo = valid.map(\.location).min(), - let hi = valid.map({ NSMaxRange($0) }).max() else { return nil } - return (lo, hi) - } - let codeTokens = classified?.code ?? tokens.filter { $0.kind == .codeBlock || $0.kind == .inlineCode } - let baseFont = NSFont(name: fontName, size: fontSize) ?? NSFont.systemFont(ofSize: fontSize) - let baseDefaultLineHeight = ceil( - layoutBridge?.defaultLineHeight(for: baseFont) - ?? (baseFont.ascender - baseFont.descender + baseFont.leading) - ) - let codeBackgroundColor = configuration.services.syntaxHighlighter.backgroundColor() - let hiddenMarkerSize = configuration.markers.hiddenMarkerFontSize - let ctx = StylingContext( - nsText: nsText, - tokens: tokens, - codeTokens: codeTokens, - activeTokenIndices: activeTokenIndices, - baseFont: baseFont, + let ctx = makeStylingContext( + text: text, + fontName: fontName, + fontSize: fontSize, layoutBridge: layoutBridge, - baseDefaultLineHeight: baseDefaultLineHeight, - codeBackgroundColor: codeBackgroundColor, - latexMarkerFont: NSFont(name: fontName, size: hiddenMarkerSize) - ?? NSFont.systemFont(ofSize: hiddenMarkerSize), - configuration: configuration, + activeTokenIndices: activeTokenIndices, wikiLinkIDProvider: wikiLinkIDProvider, - scopeBounds: scopeBounds, - classified: classified + precomputedTokens: precomputedTokens, + classified: classified, + scopedRanges: scopedRanges, + configuration: configuration ) var result: [StyledRange] = [] @@ -263,6 +243,91 @@ enum MarkdownStyler { PerfTrace.note { " styleAttributes: ast=\(String(format: "%.2f", astMs))ms latex+img4=\(String(format: "%.2f", imgMs))ms styledRanges=\(result.count)" } return result } + + /// Width changes only affect table rasters and their collapsed-block + /// attributes. Bypassing the generic AST and unrelated image passes keeps + /// an all-table resize linear in the number of tables. + static func styleTableAttributes( + text: String, + fontName: String, + fontSize: CGFloat, + layoutBridge: LayoutBridge? = nil, + activeTokenIndices: Set, + wikiLinkIDProvider: @escaping (NSRange) -> String? = { _ in nil }, + precomputedTokens: [MarkdownToken]? = nil, + classified: ClassifiedStyleTokens? = nil, + scopedRanges: [NSRange]? = nil, + configuration: MarkdownEditorConfiguration = .default + ) -> [StyledRange] { + let ctx = makeStylingContext( + text: text, + fontName: fontName, + fontSize: fontSize, + layoutBridge: layoutBridge, + activeTokenIndices: activeTokenIndices, + wikiLinkIDProvider: wikiLinkIDProvider, + precomputedTokens: precomputedTokens, + classified: classified, + scopedRanges: scopedRanges, + configuration: configuration + ) + return styleTables(ctx) + } + + private static func makeStylingContext( + text: String, + fontName: String, + fontSize: CGFloat, + layoutBridge: LayoutBridge?, + activeTokenIndices: Set, + wikiLinkIDProvider: @escaping (NSRange) -> String?, + precomputedTokens: [MarkdownToken]?, + classified: ClassifiedStyleTokens?, + scopedRanges: [NSRange]?, + configuration: MarkdownEditorConfiguration + ) -> StylingContext { + let tokens = precomputedTokens ?? MarkdownTokenizer.parseTokensViaAST( + in: text, + registry: configuration.extensionRegistry + ) + let scopeBounds: (lo: Int, hi: Int)? = scopedRanges.flatMap { ranges in + let valid = ranges.filter { + $0.location != NSNotFound && $0.length > 0 + } + guard let lo = valid.map(\.location).min(), + let hi = valid.map({ NSMaxRange($0) }).max() else { + return nil + } + return (lo, hi) + } + let codeTokens = classified?.code ?? tokens.filter { + $0.kind == .codeBlock || $0.kind == .inlineCode + } + let baseFont = NSFont(name: fontName, size: fontSize) + ?? NSFont.systemFont(ofSize: fontSize) + let baseDefaultLineHeight = ceil( + layoutBridge?.defaultLineHeight(for: baseFont) + ?? (baseFont.ascender - baseFont.descender + baseFont.leading) + ) + let hiddenMarkerSize = configuration.markers.hiddenMarkerFontSize + return StylingContext( + nsText: text as NSString, + tokens: tokens, + codeTokens: codeTokens, + activeTokenIndices: activeTokenIndices, + baseFont: baseFont, + layoutBridge: layoutBridge, + baseDefaultLineHeight: baseDefaultLineHeight, + codeBackgroundColor: configuration.services.syntaxHighlighter + .backgroundColor(), + latexMarkerFont: NSFont(name: fontName, size: hiddenMarkerSize) + ?? NSFont.systemFont(ofSize: hiddenMarkerSize), + configuration: configuration, + wikiLinkIDProvider: wikiLinkIDProvider, + scopeBounds: scopeBounds, + classified: classified + ) + } } // MARK: - Shared helpers used by multiple styling extensions diff --git a/Sources/MarkdownEngine/Styling/TextStylingService.swift b/Sources/MarkdownEngine/Styling/TextStylingService.swift index 4c13d89e..74934982 100644 --- a/Sources/MarkdownEngine/Styling/TextStylingService.swift +++ b/Sources/MarkdownEngine/Styling/TextStylingService.swift @@ -11,6 +11,11 @@ import AppKit import Foundation struct TextStylingService { + enum RestyleContent { + case all + case tables + } + static func makeBaseTypingAttributes( font: NSFont, paragraphStyle: NSParagraphStyle, @@ -45,6 +50,7 @@ struct TextStylingService { return (baseFont, paragraph) } + @discardableResult static func restyle( textView: NSTextView, layoutBridge: LayoutBridge?, @@ -58,8 +64,10 @@ struct TextStylingService { precomputedTokens: [MarkdownToken]? = nil, classified: MarkdownStyler.ClassifiedStyleTokens? = nil, precomputedBlocks: [Block]? = nil, + sourceText: String? = nil, + content: RestyleContent = .all, configuration: MarkdownEditorConfiguration = .default - ) { + ) -> [NSRange] { let paragraphs = normalize(paragraphCandidates) textView.typingAttributes = makeBaseTypingAttributes( @@ -70,25 +78,52 @@ struct TextStylingService { guard !paragraphs.isEmpty else { textView.setNeedsDisplay(textView.visibleRect) - return + return [] } let styleT0 = DispatchTime.now().uptimeNanoseconds - let styledRanges = MarkdownStyler.styleAttributes( - text: textView.string, - fontName: baseFont.fontName, - fontSize: baseFont.pointSize, - layoutBridge: layoutBridge, - caretLocation: caretLocation, - selection: selection, - activeTokenIndices: activeTokenIndices, - wikiLinkIDProvider: wikiLinkIDProvider, - precomputedTokens: precomputedTokens, - classified: classified, - precomputedBlocks: precomputedBlocks, - scopedRanges: paragraphs, - configuration: configuration - ) + let text = sourceText ?? textView.string + let styledRanges: [StyledRange] + switch content { + case .all: + styledRanges = MarkdownStyler.styleAttributes( + text: text, + fontName: baseFont.fontName, + fontSize: baseFont.pointSize, + layoutBridge: layoutBridge, + caretLocation: caretLocation, + selection: selection, + activeTokenIndices: activeTokenIndices, + wikiLinkIDProvider: wikiLinkIDProvider, + precomputedTokens: precomputedTokens, + classified: classified, + precomputedBlocks: precomputedBlocks, + scopedRanges: paragraphs, + configuration: configuration + ) + case .tables: + styledRanges = MarkdownStyler.styleTableAttributes( + text: text, + fontName: baseFont.fontName, + fontSize: baseFont.pointSize, + layoutBridge: layoutBridge, + activeTokenIndices: activeTokenIndices, + wikiLinkIDProvider: wikiLinkIDProvider, + precomputedTokens: precomputedTokens, + classified: classified, + scopedRanges: paragraphs, + configuration: configuration + ) + } + let wideTableAnchorRanges: [NSRange] + switch content { + case .all: + wideTableAnchorRanges = [] + case .tables: + wideTableAnchorRanges = styledRanges.compactMap { range, attrs in + attrs[.scrollableBlockSourceID] is Int ? range : nil + } + } let styleMs = Double(DispatchTime.now().uptimeNanoseconds - styleT0) / 1_000_000 let spellT0 = DispatchTime.now().uptimeNanoseconds @@ -125,6 +160,7 @@ struct TextStylingService { (textView as? NativeTextView)?.ensureVisibleLayout() let evlMs = Double(DispatchTime.now().uptimeNanoseconds - evlT0) / 1_000_000 PerfTrace.note { " restyle split: styleAttrs=\(String(format: "%.2f", styleMs))ms spell=\(String(format: "%.2f", spellMs))ms attrApply(paras=\(paragraphs.count))=\(String(format: "%.2f", attrMs))ms ensureVisLayout=\(String(format: "%.2f", evlMs))ms" } + return wideTableAnchorRanges } /// Lays the base attributes down per paragraph and paints the styled ranges over diff --git a/Sources/MarkdownEngine/TextView/ClampedScrollView.swift b/Sources/MarkdownEngine/TextView/ClampedScrollView.swift index 0cec7c5d..fa52fa7e 100644 --- a/Sources/MarkdownEngine/TextView/ClampedScrollView.swift +++ b/Sources/MarkdownEngine/TextView/ClampedScrollView.swift @@ -13,6 +13,39 @@ final class ClampedScrollView: NSScrollView { /// own height to SwiftUI and the enclosing scroll view owns paging. var fitsContent: Bool = false + /// AppKit can process a physical window resize inside a nested tracking + /// loop without servicing deferred run-loop blocks. Keep explicit state so + /// width-dependent rendering can finish before each frame-size callback + /// returns instead of waiting for mouse-up. + private(set) var isLiveResizeActive = false + private var isAwaitingPostLiveResizeWidthUpdate = false + + /// SwiftUI can deliver the final document-view width after AppKit's + /// `viewDidEndLiveResize`. Keep the synchronous path armed until that + /// concrete width update arrives; already-settled geometry never arms it. + var requiresSynchronousTableWidthUpdate: Bool { + isLiveResizeActive || isAwaitingPostLiveResizeWidthUpdate + } + + func acknowledgePostLiveResizeWidthUpdate() { + guard !isLiveResizeActive, isAwaitingPostLiveResizeWidthUpdate else { + return + } + isAwaitingPostLiveResizeWidthUpdate = false + } + + private var hasUnpropagatedLiveResizeWidth: Bool { + guard let container = documentView as? NativeTextViewContainer, + let textView = container.textView else { + return false + } + let viewportWidth = contentView.bounds.width + guard viewportWidth.isFinite, viewportWidth >= 0 else { return false } + if container.bounds.width != viewportWidth { return true } + return textView.configuration.readingWidth == nil + && textView.frame.width != container.bounds.width + } + /// 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? @@ -90,12 +123,20 @@ final class ClampedScrollView: NSScrollView { override func viewWillStartLiveResize() { super.viewWillStartLiveResize() + isLiveResizeActive = true + isAwaitingPostLiveResizeWidthUpdate = false guard !fitsContent else { return } scrollYBeforeLiveResize = contentView.bounds.origin.y } override func viewDidEndLiveResize() { super.viewDidEndLiveResize() + nativeTextView?.flushPendingTableWidthChangeUpdate() + isLiveResizeActive = false + // A lagging SwiftUI document view is observable as a width mismatch. + // Keep synchronous table rendering armed until that concrete geometry + // propagation is consumed; never guess its delivery time with a timer. + isAwaitingPostLiveResizeWidthUpdate = hasUnpropagatedLiveResizeWidth guard !fitsContent else { return } if let y = scrollYBeforeLiveResize { contentView.scroll(to: NSPoint(x: contentView.bounds.origin.x, y: y)) diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index 7133867b..50763e5c 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -189,21 +189,22 @@ extension NativeTextViewCoordinator { // Reconcile wide-table overlays after layout settles. if let nativeTextView = textView as? NativeTextView { - DispatchQueue.main.async { [weak nativeTextView] in - nativeTextView?.updateWideTableOverlays() - } + nativeTextView.updateWideTableOverlays() } } + @discardableResult func restyleTextView( _ textView: NSTextView, paragraphCandidates: [NSRange], tokens: [MarkdownToken]? = nil, classified: MarkdownStyler.ClassifiedStyleTokens? = nil, - blocks: [Block]? = nil - ) { + blocks: [Block]? = nil, + sourceText: String? = nil, + content: TextStylingService.RestyleContent = .all + ) -> [NSRange] { // Raw mode: no restyling; typing keeps base attrs via the typing shim. - guard !configuration.rawSourceMode else { return } + guard !configuration.rawSourceMode else { return [] } let (baseFont, paragraphStyle) = TextStylingService.makeBaseFontAndStyle( fontName: fontName, fontSize: fontSize, @@ -211,7 +212,7 @@ extension NativeTextViewCoordinator { configuration: configuration ) - TextStylingService.restyle( + let wideTableAnchorRanges = TextStylingService.restyle( textView: textView, layoutBridge: layoutBridge, paragraphCandidates: paragraphCandidates, @@ -229,14 +230,18 @@ extension NativeTextViewCoordinator { precomputedTokens: tokens, classified: classified, precomputedBlocks: blocks, + sourceText: sourceText, + content: content, configuration: configuration ) - // Reconcile wide-table overlays after layout settles. - if let nativeTextView = textView as? NativeTextView { - DispatchQueue.main.async { [weak nativeTextView] in - nativeTextView?.updateWideTableOverlays() - } + // Width-specific callers already have the exact new wide-table anchor + // set and reconcile it after the one full height layout. Ordinary + // styling still uses the coalesced storage-discovery path. + if case .all = content, + let nativeTextView = textView as? NativeTextView { + nativeTextView.updateWideTableOverlays() } + return wideTableAnchorRanges } func parsedDocument(for text: String, edit: ParseEditDescriptor? = nil) -> ParsedDocument { @@ -301,6 +306,10 @@ extension NativeTextViewCoordinator { } } + let nsText = text as NSString + let tableParagraphRanges = tableTokens.compactMap { + $0.standaloneParagraphRange(in: nsText) + } parsedDocumentVersion &+= 1 let parsed = ParsedDocument( tokens: tokens, @@ -311,6 +320,7 @@ extension NativeTextViewCoordinator { wikiLinkTokens: wikiLinkTokens, imageEmbedTokens: imageEmbedTokens, tableTokens: tableTokens, + tableParagraphRanges: tableParagraphRanges, codeBlockTokensWithIndices: codeBlockTokensWithIndices, classified: MarkdownStyler.ClassifiedStyleTokens( inlineLatex: inlineLatexIdx, blockLatex: blockLatexIdx, @@ -410,6 +420,43 @@ extension NativeTextViewCoordinator { classified: parsed.classified, blocks: parsed.blocks) } + /// Width changes cannot alter non-table Markdown styling. Reuse the + /// current parse and its indexed table paragraphs, then run only the table + /// renderer so a resize does not traverse the generic AST or unrelated + /// image passes. + func restyleTablesForWidthChange(in textView: NSTextView) -> [NSRange] { + let documentText: String + let parsed: ParsedDocument + if cachedParseGeneration == parseGeneration, + cachedParsedLength == textView.textStorage?.length, + let cachedParsedText, + let cachedParsedDocument { + documentText = cachedParsedText + parsed = cachedParsedDocument + } else { + documentText = textView.string + parsed = parsedDocument(for: documentText) + } + guard !parsed.tableParagraphRanges.isEmpty else { return [] } + + let nsText = documentText as NSString + activeTokenIndices = activeTokenIndices( + parsed: parsed, + selection: textView.selectedRange(), + in: nsText, + suppressed: !textView.isEditable + ) + return restyleTextView( + textView, + paragraphCandidates: parsed.tableParagraphRanges, + tokens: parsed.tokens, + classified: parsed.classified, + blocks: parsed.blocks, + sourceText: documentText, + content: .tables + ) + } + func applyInlineReplacement(_ request: InlineReplacementRequest, to textView: NSTextView) { lastAppliedInlineReplacementID = request.id diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index aa30264b..c5567ddc 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -238,6 +238,9 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { let wikiLinkTokens: [MarkdownToken] let imageEmbedTokens: [MarkdownToken] let tableTokens: [MarkdownToken] + /// Standalone table paragraphs, computed once with the parse instead + /// of rediscovering them from every attributed run on each resize. + let tableParagraphRanges: [NSRange] /// Code-block tokens with their index into `tokens` (active-token /// checks need the original index) — collected in the same single /// classification pass instead of a per-call full-token filter. diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift index 2a56ea99..69123b30 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift @@ -234,17 +234,22 @@ extension NativeTextView { func centerReadingColumn(forClipWidth clipWidth: CGFloat) { guard configuration.readingWidth != nil, let container = superview as? NativeTextViewContainer else { return } - if abs(container.frame.size.width - clipWidth) > 0.5 { + if container.frame.size.width != clipWidth { var f = container.frame f.size.width = max(clipWidth, 0) container.frame = f } let originX = floor(max(0, (clipWidth - readingColumnWidth) / 2)) let delta = originX - frame.origin.x - if abs(delta) > 0.5 { + if delta != 0 { setFrameOrigin(NSPoint(x: originX, y: frame.origin.y)) - repositionWideTableOverlaysForWidthChange(insetDelta: delta) } + // The breakout host keeps changing width after a narrow viewport has + // pinned the reading column to x = 0. Resize overlays on every host + // width change, even when the column itself no longer moves. + repositionWideTableOverlaysForWidthChange(insetDelta: delta) + (enclosingScrollView as? ClampedScrollView)? + .acknowledgePostLiveResizeWidthUpdate() } override func setFrameSize(_ newSize: NSSize) { @@ -259,7 +264,10 @@ extension NativeTextView { return } - let widthChanged = abs(newSize.width - frame.size.width) > 0.5 + // Fractional split-view and SwiftUI layouts are real render inputs. + // Dropping sub-point changes leaves both the text container and a + // width-adaptive table at the previous width. + let widthChanged = newSize.width != frame.size.width if widthChanged { pendingFullLayoutMeasure = true // re-wrap → re-measure height against a full layout isApplyingManagedFrameSize = true @@ -267,36 +275,74 @@ extension NativeTextView { isApplyingManagedFrameSize = false } - recalcOverscroll(for: scrollView, targetWidth: newSize.width, debugTag: "setFrameSize") - - // Width change → only rendered table paragraphs need restyling. Their image - // width can change, and an initially narrow table can become scrollable. if widthChanged { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - if self.configuration.readingWidth == nil { - self.restyleTableParagraphsForWidthChange() - } - self.updateWideTableOverlays() + if let clampedScrollView = scrollView as? ClampedScrollView, + clampedScrollView.requiresSynchronousTableWidthUpdate { + // A physical AppKit resize may remain inside its nested event + // tracking loop until mouse-up. Deferred blocks are therefore + // not a reliable paint boundary: finish the width-dependent + // table transaction before this resize frame returns. + pendingTableWidthChangeUpdate = true + flushPendingTableWidthChangeUpdate() + clampedScrollView.acknowledgePostLiveResizeWidthUpdate() + } else { + // Outside live resize, collapse same-turn programmatic writes + // into one transaction that reads the latest width. + scheduleTableWidthChangeUpdate() } + } else { + recalcOverscroll( + for: scrollView, + targetWidth: newSize.width, + debugTag: "setFrameSize" + ) } } - /// Restyle only table paragraphs via stamped anchor ranges; avoids re-tokenizing the doc. - private func restyleTableParagraphsForWidthChange() { - guard let storage = textStorage, - let coord = delegate as? NativeTextViewCoordinator else { return } - var ranges: [NSRange] = [] - var seen: Set = [] - let fullRange = NSRange(location: 0, length: storage.length) - storage.enumerateAttribute(.scrollableBlockFullRange, in: fullRange, options: []) { value, _, _ in - guard let v = value as? NSValue else { return } - let r = v.rangeValue - let key = "\(r.location):\(r.length)" - if seen.insert(key).inserted { ranges.append(r) } + /// Schedules against both modes AppKit uses while resizing so table + /// rendering runs at the next run-loop opportunity without a fixed delay. + /// Width writes in the same turn collapse into one restyle that reads the + /// latest container width. + private func scheduleTableWidthChangeUpdate() { + guard !pendingTableWidthChangeUpdate else { return } + pendingTableWidthChangeUpdate = true + RunLoop.main.perform(inModes: [.default, .eventTracking]) { + [weak self] in + MainActor.assumeIsolated { + self?.flushPendingTableWidthChangeUpdate() + } } - guard !ranges.isEmpty else { return } - coord.restyleParagraphs(ranges, in: self) + } + + /// Applies the latest width immediately. The scroll view calls this when a + /// live resize ends so mouse-up cannot leave a scheduled final-width tail. + func flushPendingTableWidthChangeUpdate() { + guard pendingTableWidthChangeUpdate else { return } + pendingTableWidthChangeUpdate = false + performTableWidthChangeUpdate() + } + + private func performTableWidthChangeUpdate() { + var layoutAlreadySettled = false + var wideTableAnchorRanges: [NSRange]? + if configuration.readingWidth == nil { + wideTableAnchorRanges = (delegate as? NativeTextViewCoordinator)? + .restyleTablesForWidthChange(in: self) + if let scrollView = enclosingScrollView { + pendingFullLayoutMeasure = true + recalcOverscroll( + for: scrollView, + targetWidth: frame.width, + debugTag: "tableWidthChange" + ) + layoutAlreadySettled = true + } + } + updateWideTableOverlays( + immediately: true, + layoutAlreadySettled: layoutAlreadySettled, + knownWideTableAnchorRanges: wideTableAnchorRanges + ) } override func scrollRangeToVisible(_ range: NSRange) { diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift index c263d415..42db6d49 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift @@ -25,6 +25,8 @@ final class NativeTextView: NSTextView { var pendingFullLayoutMeasure = false /// Coalesces wide-table overlay updates to once per runloop (resize fires many per frame). var pendingWideTableOverlayUpdate = false + /// Coalesces table restyles to the latest width once per run-loop turn. + var pendingTableWidthChangeUpdate = false var suppressAutoRevealOnce: Bool = false // Set by clickedOnLink during a mouseDown: did the delegate fire (so // mouseDown can re-dispatch a click AppKit dropped), and did it navigate diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewContainer.swift b/Sources/MarkdownEngine/TextView/NativeTextViewContainer.swift index c1096047..00aa284e 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewContainer.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewContainer.swift @@ -74,7 +74,7 @@ final class NativeTextViewContainer: NSView { // Reading column: the column keeps its fixed width; re-center its X // (and shift the wide-table overlay insets) instead of resizing. textView.centerReadingColumn(forClipWidth: w) - } else if abs(textView.frame.width - w) > 0.5 { + } else if textView.frame.width != w { textView.setFrameSize(NSSize(width: w, height: textView.frame.height)) } } diff --git a/Tests/MarkdownEngineTests/TableImageCacheTests.swift b/Tests/MarkdownEngineTests/TableImageCacheTests.swift index f0cbca53..aa78d045 100644 --- a/Tests/MarkdownEngineTests/TableImageCacheTests.swift +++ b/Tests/MarkdownEngineTests/TableImageCacheTests.swift @@ -67,6 +67,41 @@ struct TableImageCacheTests { #expect(first.image === second.image) } + @Test func fractionalWidthsDoNotShareAStaleImage() throws { + let source = "| fractional | width |\n|---|---|\n| a wrapping value | b |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let ctx = makeContext(for: source) + let aqua = try #require(NSAppearance(named: .aqua)) + + let first = MarkdownStyler.tableImage( + for: source, + parsed: parsed, + ctx: ctx, + appearance: aqua, + availableWidth: 659.51 + ) + let second = MarkdownStyler.tableImage( + for: source, + parsed: parsed, + ctx: ctx, + appearance: aqua, + availableWidth: 660.49 + ) + let repeatedFirst = MarkdownStyler.tableImage( + for: source, + parsed: parsed, + ctx: ctx, + appearance: aqua, + availableWidth: 659.51 + ) + + #expect(first.rendered) + #expect(second.rendered) + #expect(first.image !== second.image) + #expect(!repeatedFirst.rendered) + #expect(repeatedFirst.image === first.image) + } + @Test func appearanceChangeRendersFresh() throws { let source = "| gamma | delta |\n|---|---|\n| 3 | 4 |" let parsed = try #require(MarkdownStyler.parseTableSource(source)) diff --git a/Tests/MarkdownEngineTests/TableWidthChangeRestyleTests.swift b/Tests/MarkdownEngineTests/TableWidthChangeRestyleTests.swift index 25e9aca7..6fcc23f6 100644 --- a/Tests/MarkdownEngineTests/TableWidthChangeRestyleTests.swift +++ b/Tests/MarkdownEngineTests/TableWidthChangeRestyleTests.swift @@ -5,12 +5,401 @@ import AppKit import Foundation +import SwiftUI import Testing @testable import MarkdownEngine -@Suite("Table width-change restyling") +@MainActor +@Suite("Table width-change restyling", .serialized) struct TableWidthChangeRestyleTests { + private static let wrappingTable = """ + # Width-change fixture + + | Novel | Opening line | + |---|---| + | Der Zauberberg (1924) | Ein einfacher junger Mensch reiste im Hochsommer von Hamburg, seiner Vaterstadt, nach Davos-Platz im Graubündischen. | + | The Master and Margarita (1966–67) | At the sunset hour of one warm spring day two men were to be seen at Patriarch's Ponds. | + | The Picture of Dorian Gray (1890) | The studio was filled with the rich odour of roses, and when the light summer wind stirred amidst the trees of the garden. | + + ## Tail + + The table remains inactive while its container changes width. + """ + + private static let wideTable = """ + # Wide-table overlay fixture + + | PositionIdentifierThatCannotBreakAcrossLines | MarketViewIdentifierThatCannotBreakAcrossLines | MaximumLossAtExpiryIdentifierThatCannotBreakAcrossLines | UpsideAtExpiryIdentifierThatCannotBreakAcrossLines | + |---|---|---|---| + | LongCallIdentifierThatCannotBreakAcrossLines | BullishWithLimitedDownsideIdentifierThatCannotBreakAcrossLines | PremiumPaidIdentifierThatCannotBreakAcrossLines | TheoreticallyUnlimitedIdentifierThatCannotBreakAcrossLines | + | ShortCallIdentifierThatCannotBreakAcrossLines | NeutralOrBearishIdentifierThatCannotBreakAcrossLines | TheoreticallyUnlimitedIdentifierThatCannotBreakAcrossLines | PremiumReceivedIdentifierThatCannotBreakAcrossLines | + + ## Tail + + The table stays inactive while its visible overlay changes width. + """ + + private static let optionPricingTable = """ + # Option pricing fixture + + | Position | Market view | Maximum loss at expiry | Upside at expiry | + |:--|:--|--:|:--| + | Long call | Bullish, with limited downside | Premium paid | Theoretically unlimited | + | Short call | Neutral or bearish | Theoretically unlimited | Premium received | + | Long put | Bearish or protective | Premium paid | Limited by $S_T \\ge 0$ | + | Short put | Neutral or bullish | Large but limited | Premium received | + + ## Tail + + The table stays inactive while its visible geometry changes width. + """ + + private static func tables(count: Int) -> String { + (0.. RenderedTable? { + guard let storage = textView.textStorage, + storage.length > 0 else { return nil } + var result: RenderedTable? + storage.enumerateAttribute( + .scrollableBlockFullRange, + in: NSRange(location: 0, length: storage.length), + options: [] + ) { value, range, stop in + guard value != nil, + let image = storage.attribute( + .latexImage, + at: range.location, + effectiveRange: nil + ) as? NSImage, + let bounds = ( + storage.attribute( + .latexBounds, + at: range.location, + effectiveRange: nil + ) as? NSValue + )?.rectValue else { return } + result = RenderedTable(image: image, bounds: bounds) + stop.pointee = true + } + return result + } + + static func renderedTables( + in textView: NSTextView + ) -> [NSRange: RenderedTable] { + guard let storage = textView.textStorage, + storage.length > 0 else { return [:] } + var result: [NSRange: RenderedTable] = [:] + storage.enumerateAttribute( + .scrollableBlockFullRange, + in: NSRange(location: 0, length: storage.length), + options: [] + ) { value, range, _ in + guard let fullRange = (value as? NSValue)?.rangeValue, + result[fullRange] == nil, + let image = storage.attribute( + .latexImage, + at: range.location, + effectiveRange: nil + ) as? NSImage, + let bounds = ( + storage.attribute( + .latexBounds, + at: range.location, + effectiveRange: nil + ) as? NSValue + )?.rectValue else { return } + result[fullRange] = RenderedTable( + image: image, + bounds: bounds + ) + } + return result + } + + struct VisibleWideTable { + let image: NSImage + let frame: CGRect + } + + static func visibleWideTable(in textView: NativeTextView) -> VisibleWideTable? { + guard let overlay = textView.wideTableOverlays.values.first, + let imageView = overlay.documentView as? NSImageView, + let image = imageView.image else { return nil } + return VisibleWideTable(image: image, frame: overlay.frame) + } + + static func drain(mode: RunLoop.Mode, duration: TimeInterval) { + let deadline = Date(timeIntervalSinceNow: duration) + while Date() < deadline { + RunLoop.main.run( + mode: mode, + before: min(deadline, Date(timeIntervalSinceNow: 0.01)) + ) + } + } + + func resizeWindow(to width: CGFloat, display: Bool = true) { + window.setContentSize( + NSSize(width: width, height: window.contentLayoutRect.height) + ) + window.layoutIfNeeded() + if display { + window.displayIfNeeded() + } + } + + func resizeDocumentContainer(to width: CGFloat) throws { + let container = try #require( + textView.superview as? NativeTextViewContainer + ) + container.setFrameSize( + NSSize(width: width, height: container.frame.height) + ) + } + + func runInEventTracking( + widths: [CGFloat], + interval: TimeInterval = 0.03 + ) -> RenderedTable? { + driveInEventTracking(widths: widths, interval: interval).result + } + + func renderedWidthsDuringEventTracking( + widths: [CGFloat], + interval: TimeInterval = 0.03 + ) -> [CGFloat] { + driveInEventTracking( + widths: widths, + interval: interval + ).renderedWidths + } + + private func driveInEventTracking( + widths: [CGFloat], + interval: TimeInterval + ) -> EventTrackingDriver { + let scrollView = textView.enclosingScrollView + scrollView?.viewWillStartLiveResize() + defer { scrollView?.viewDidEndLiveResize() } + let driver = EventTrackingDriver( + window: window, + textView: textView, + widths: widths + ) + let timer = Timer( + timeInterval: interval, + target: driver, + selector: #selector(EventTrackingDriver.advance(_:)), + userInfo: nil, + repeats: true + ) + RunLoop.main.add(timer, forMode: .eventTracking) + let deadline = Date(timeIntervalSinceNow: 2) + while !driver.finished, Date() < deadline { + RunLoop.main.run( + mode: .eventTracking, + before: Date(timeIntervalSinceNow: 0.05) + ) + } + timer.invalidate() + return driver + } + } + + @MainActor + private final class EventTrackingDriver: NSObject { + let window: NSWindow + let textView: NativeTextView + let widths: [CGFloat] + var index = 0 + var result: Harness.RenderedTable? + var renderedWidths: [CGFloat] = [] + var finished = false + + init( + window: NSWindow, + textView: NativeTextView, + widths: [CGFloat] + ) { + self.window = window + self.textView = textView + self.widths = widths + } + + @objc func advance(_ timer: Timer) { + if index > 0, + let rendered = Harness.renderedTable(in: textView) + { + renderedWidths.append(rendered.bounds.width) + } + guard index < widths.count else { + result = Harness.renderedTable(in: textView) + finished = true + timer.invalidate() + return + } + let width = widths[index] + index += 1 + window.setContentSize( + NSSize(width: width, height: window.contentLayoutRect.height) + ) + window.layoutIfNeeded() + window.displayIfNeeded() + } + } + + @MainActor + private final class EditRecorder: NSObject { + var count = 0 + + @objc func storageDidProcessEditing(_ notification: Notification) { + count += 1 + } + } + @Test("Initially narrow tables stamp their paragraph for width-change restyling") func narrowTableStampsWidthChangeRange() throws { _ = NSApplication.shared @@ -45,4 +434,493 @@ struct TableWidthChangeRestyleTests { ).rangeValue #expect(stampedRange == nsText.paragraphRange(for: tableToken.range)) } + + @Test("Tracked table width follows live text-view bounds when its container lags") + func trackedTableWidthUsesLiveTextViewBounds() throws { + _ = NSApplication.shared + let textView = NativeTextView( + frame: NSRect(x: 0, y: 0, width: 680, height: 400) + ) + let textContainer = try #require(textView.textContainer) + let textLayoutManager = try #require(textView.textLayoutManager) + textView.textContainerInset = NSSize(width: 48, height: 0) + textContainer.widthTracksTextView = false + textContainer.size = NSSize(width: 804, height: 10_000) + textContainer.widthTracksTextView = true + let font = NSFont.systemFont(ofSize: 16) + let context = MarkdownStyler.StylingContext( + nsText: "" as NSString, + tokens: [], + codeTokens: [], + activeTokenIndices: [], + baseFont: font, + layoutBridge: LayoutBridge(textLayoutManager), + baseDefaultLineHeight: 19, + codeBackgroundColor: .windowBackgroundColor, + latexMarkerFont: font, + configuration: .default, + wikiLinkIDProvider: { _ in nil } + ) + + #expect(textContainer.size.width == 804) + #expect(MarkdownStyler.effectiveContainerWidth(for: context) == 584) + } + + @Test("Fixed table width follows its explicit text-container width") + func fixedTableWidthUsesExplicitTextContainerWidth() throws { + _ = NSApplication.shared + let textView = NativeTextView( + frame: NSRect(x: 0, y: 0, width: 680, height: 400) + ) + let textContainer = try #require(textView.textContainer) + let textLayoutManager = try #require(textView.textLayoutManager) + textView.textContainerInset = NSSize(width: 48, height: 0) + textContainer.widthTracksTextView = false + textContainer.size = NSSize(width: 500, height: 10_000) + let font = NSFont.systemFont(ofSize: 16) + let context = MarkdownStyler.StylingContext( + nsText: "" as NSString, + tokens: [], + codeTokens: [], + activeTokenIndices: [], + baseFont: font, + layoutBridge: LayoutBridge(textLayoutManager), + baseDefaultLineHeight: 19, + codeBackgroundColor: .windowBackgroundColor, + latexMarkerFont: font, + configuration: .default, + wikiLinkIDProvider: { _ in nil } + ) + + #expect(MarkdownStyler.effectiveContainerWidth(for: context) == 500) + } + + @Test("Live window resize applies the table width before layout returns") + func liveWindowResizeAppliesTableWidthSynchronously() throws { + let harness = try Harness() + defer { harness.close() } + let scrollView = try #require(harness.textView.enclosingScrollView) + let initial = try #require( + Harness.renderedTable(in: harness.textView) + ) + + scrollView.viewWillStartLiveResize() + defer { scrollView.viewDidEndLiveResize() } + harness.resizeWindow(to: 580, display: false) + let immediate = try #require( + Harness.renderedTable(in: harness.textView) + ) + + #expect(immediate.image !== initial.image) + #expect(abs(immediate.bounds.width - 579) <= 1) + #expect(!harness.textView.pendingTableWidthChangeUpdate) + } + + @Test("Post-resize hosting layout still applies the table width synchronously") + func postResizeHostingLayoutAppliesTableWidthSynchronously() throws { + let harness = try Harness() + defer { harness.close() } + let scrollView = try #require(harness.textView.enclosingScrollView) + let initial = try #require( + Harness.renderedTable(in: harness.textView) + ) + + // Model SwiftUI hosting with a final-width clip view whose document + // container is still one layout pass behind. + scrollView.viewWillStartLiveResize() + harness.resizeWindow(to: 580, display: false) + try harness.resizeDocumentContainer(to: 900) + scrollView.viewDidEndLiveResize() + try harness.resizeDocumentContainer(to: 580) + let immediate = try #require( + Harness.renderedTable(in: harness.textView) + ) + + #expect(immediate.image !== initial.image) + #expect(abs(immediate.bounds.width - 579) <= 1) + #expect(!harness.textView.pendingTableWidthChangeUpdate) + } + + @Test("Deferred post-resize hosting layout applies before its callback returns") + func deferredPostResizeHostingLayoutAppliesSynchronously() throws { + let harness = try Harness() + defer { harness.close() } + let scrollView = try #require(harness.textView.enclosingScrollView) + let initial = try #require( + Harness.renderedTable(in: harness.textView) + ) + var immediate: Harness.RenderedTable? + var pendingAtReturn = false + var didApplyTrailingLayout = false + + scrollView.viewWillStartLiveResize() + harness.resizeWindow(to: 580, display: false) + try harness.resizeDocumentContainer(to: 900) + scrollView.viewDidEndLiveResize() + RunLoop.main.perform(inModes: [.default]) { + MainActor.assumeIsolated { + try? harness.resizeDocumentContainer(to: 580) + immediate = Harness.renderedTable(in: harness.textView) + pendingAtReturn = harness.textView.pendingTableWidthChangeUpdate + didApplyTrailingLayout = true + } + } + let deadline = Date(timeIntervalSinceNow: 0.5) + while !didApplyTrailingLayout, Date() < deadline { + RunLoop.main.run( + mode: .default, + before: Date(timeIntervalSinceNow: 0.01) + ) + } + + let rendered = try #require(immediate) + #expect(rendered.image !== initial.image) + #expect(abs(rendered.bounds.width - 579) <= 1) + #expect(!pendingAtReturn) + } + + @Test("Delayed post-resize hosting layout remains synchronous until consumed") + func delayedPostResizeHostingLayoutRemainsSynchronous() async throws { + let harness = try Harness() + defer { harness.close() } + let scrollView = try #require(harness.textView.enclosingScrollView) + let initial = try #require( + Harness.renderedTable(in: harness.textView) + ) + + scrollView.viewWillStartLiveResize() + harness.resizeWindow(to: 580, display: false) + try harness.resizeDocumentContainer(to: 900) + scrollView.viewDidEndLiveResize() + try await Task.sleep(for: .milliseconds(350)) + + try harness.resizeDocumentContainer(to: 580) + let immediate = try #require( + Harness.renderedTable(in: harness.textView) + ) + + #expect(immediate.image !== initial.image) + #expect(abs(immediate.bounds.width - 579) <= 1) + #expect(!harness.textView.pendingTableWidthChangeUpdate) + } + + @Test("Settled live resize does not arm a later ordinary width update") + func settledLiveResizeDisarmsPostResizeSynchronization() throws { + let harness = try Harness() + defer { harness.close() } + let scrollView = try #require( + harness.textView.enclosingScrollView as? ClampedScrollView + ) + + scrollView.viewWillStartLiveResize() + scrollView.viewDidEndLiveResize() + + #expect(!scrollView.requiresSynchronousTableWidthUpdate) + } + + @Test("Every live-resize interval renders its intermediate table width") + func everyLiveResizeIntervalRendersIntermediateTableWidth() throws { + let harness = try Harness() + defer { harness.close() } + + let renderedWidths = harness.renderedWidthsDuringEventTracking( + widths: [820, 760, 700, 640, 580], + interval: 0.04 + ) + + #expect(renderedWidths.count == 5) + for (rendered, expected) in zip( + renderedWidths, + [819.0, 759.0, 699.0, 639.0, 579.0] + ) { + #expect(abs(rendered - expected) <= 1) + } + } + + @Test("Live resize rerenders every table before resize ends") + func liveResizeRerendersEveryTableBeforeResizeEnds() throws { + let harness = try Harness(source: Self.manyTables) + defer { harness.close() } + let initial = Harness.renderedTables(in: harness.textView) + let orderedRanges = initial.keys.sorted { $0.location < $1.location } + let firstRange = try #require(orderedRanges.first) + let lastRange = try #require(orderedRanges.last) + let initialFirst = try #require(initial[firstRange]) + let initialLast = try #require(initial[lastRange]) + let scrollView = try #require(harness.textView.enclosingScrollView) + + scrollView.viewWillStartLiveResize() + harness.resizeWindow(to: 580, display: false) + let live = Harness.renderedTables(in: harness.textView) + #expect(live.count == initial.count) + let liveFirst = try #require(live[firstRange]) + let liveLast = try #require(live[lastRange]) + + #expect(liveFirst.image !== initialFirst.image) + #expect(liveLast.image !== initialLast.image) + #expect(abs(liveLast.bounds.width - 579) <= 1) + scrollView.viewDidEndLiveResize() + let settledLast = try #require( + Harness.renderedTables(in: harness.textView)[lastRange] + ) + #expect(settledLast.image === liveLast.image) + #expect(abs(settledLast.bounds.width - 579) <= 1) + } + + @Test("Table reflow updates document height before tracking ends") + func tableReflowUpdatesDocumentHeightBeforeTrackingEnds() throws { + let harness = try Harness( + source: Self.optionPricingTable, + horizontalTextInset: 48 + ) + defer { harness.close() } + let initialHeight = harness.textView.baseContentHeight + + let live = try #require( + harness.runInEventTracking(widths: [760, 680, 600, 520]) + ) + + #expect(live.bounds.height > 200) + #expect(harness.textView.baseContentHeight > initialHeight) + #expect( + harness.textView.scrollableContentHeight + >= harness.textView.baseContentHeight + ) + } + + @Test("Wide-table overlay updates before event tracking ends") + func wideTableOverlayUpdatesBeforeEventTrackingEnds() throws { + let harness = try Harness(source: Self.wideTable) + defer { harness.close() } + let initialStorage = try #require( + Harness.renderedTable(in: harness.textView) + ) + #expect(initialStorage.bounds.width > 900) + let initialOverlay = try #require( + Harness.visibleWideTable(in: harness.textView) + ) + + let liveStorage = try #require( + harness.runInEventTracking(widths: [820, 760, 700, 640, 580]) + ) + let liveOverlay = try #require( + Harness.visibleWideTable(in: harness.textView) + ) + + #expect(liveOverlay.image !== initialOverlay.image) + #expect(liveOverlay.image === liveStorage.image) + #expect(abs(liveOverlay.frame.width - 580) <= 1) + } + + @Test("Fixed-width table overlay follows its host below the reading width") + func fixedWidthTableOverlayFollowsNarrowerHost() throws { + let harness = try Harness( + source: Self.wideTable, + readingWidth: 500 + ) + defer { harness.close() } + let initial = try #require( + Harness.visibleWideTable(in: harness.textView) + ) + + try harness.resizeDocumentContainer(to: 480) + let firstNarrow = try #require( + Harness.visibleWideTable(in: harness.textView) + ) + try harness.resizeDocumentContainer(to: 460) + let secondNarrow = try #require( + Harness.visibleWideTable(in: harness.textView) + ) + + #expect(firstNarrow.image === initial.image) + #expect(secondNarrow.image === initial.image) + #expect(firstNarrow.frame.width == 480) + #expect(secondNarrow.frame.width == 460) + } + + @Test("Wrapped tables preserve symmetric outer insets while resizing") + func wrappedTablePreservesSymmetricOuterInsetsWhileResizing() async throws { + let harness = try Harness( + source: Self.optionPricingTable, + horizontalTextInset: 48 + ) + defer { harness.close() } + + var previousTableWidth: CGFloat? + for width in [700.0, 720.0, 740.0, 760.0, 780.0] { + harness.textView.setFrameSize( + NSSize(width: width, height: harness.textView.frame.height) + ) + try await Task.sleep(for: .milliseconds(30)) + let table = try #require(Harness.renderedTable(in: harness.textView)) + let containerWidth = try #require(harness.textView.textContainer?.size.width) + + #expect(abs(table.bounds.width - containerWidth) < 0.25) + if let previousTableWidth { + #expect(abs(table.bounds.width - previousTableWidth - 20) < 0.25) + } + previousTableWidth = table.bounds.width + } + } + + @Test("Fractional width changes propagate without a threshold") + func fractionalWidthChangesPropagateWithoutThreshold() throws { + let harness = try Harness( + source: Self.optionPricingTable, + horizontalTextInset: 48 + ) + defer { harness.close() } + + try harness.resizeDocumentContainer(to: 659.75) + Harness.drain(mode: .eventTracking, duration: 0.03) + let first = try #require( + Harness.renderedTable(in: harness.textView) + ) + + try harness.resizeDocumentContainer(to: 660) + Harness.drain(mode: .eventTracking, duration: 0.03) + let second = try #require( + Harness.renderedTable(in: harness.textView) + ) + + #expect(harness.textView.frame.width == 660) + #expect(second.image !== first.image) + #expect(abs(second.bounds.width - first.bounds.width - 0.25) < 0.01) + } + + @Test("Fractional table overflow enters scrollable mode") + func fractionalTableOverflowEntersScrollableMode() throws { + _ = NSApplication.shared + let text = Self.wideTable + let nsText = text as NSString + let tokens = MarkdownTokenizer.parseTokensViaAST(in: text) + let tableToken = try #require(tokens.first { $0.kind == .table }) + let source = nsText.substring(with: tableToken.range) + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let appearance = try #require(NSAppearance(named: .aqua)) + let font = NSFont.systemFont(ofSize: 16) + let baseContext = MarkdownStyler.StylingContext( + nsText: nsText, + tokens: tokens, + codeTokens: [], + activeTokenIndices: [], + baseFont: font, + layoutBridge: nil, + baseDefaultLineHeight: 19, + codeBackgroundColor: .windowBackgroundColor, + latexMarkerFont: font, + configuration: .default, + wikiLinkIDProvider: { _ in nil } + ) + let minimumWidth = MarkdownStyler.tableImage( + for: source, + parsed: parsed, + ctx: baseContext, + appearance: appearance, + availableWidth: 1 + ).image.size.width + let availableWidth = minimumWidth - 0.25 + let textView = NativeTextView( + frame: NSRect(x: 0, y: 0, width: availableWidth, height: 400) + ) + let textContainer = try #require(textView.textContainer) + let textLayoutManager = try #require(textView.textLayoutManager) + textContainer.widthTracksTextView = false + textContainer.size = NSSize( + width: availableWidth, + height: .greatestFiniteMagnitude + ) + let context = MarkdownStyler.StylingContext( + nsText: nsText, + tokens: tokens, + codeTokens: [], + activeTokenIndices: [], + baseFont: font, + layoutBridge: LayoutBridge(textLayoutManager), + baseDefaultLineHeight: 19, + codeBackgroundColor: .windowBackgroundColor, + latexMarkerFont: font, + configuration: .default, + wikiLinkIDProvider: { _ in nil } + ) + + let attributes = MarkdownStyler.styleTables(context) + let anchor = try #require(attributes.first { + $0.attributes[.scrollableBlockFullRange] != nil + }) + + #expect(anchor.attributes[.scrollableBlockNaturalWidth] != nil) + } + + @Test("Wide tables leave scrollable mode within one display frame") + func wideTableLeavesScrollableModeWithinOneDisplayFrame() throws { + let harness = try Harness( + source: Self.optionPricingTable, + horizontalTextInset: 48 + ) + defer { harness.close() } + + harness.textView.setFrameSize( + NSSize(width: 400, height: harness.textView.frame.height) + ) + Harness.drain(mode: .default, duration: 0.03) + _ = try #require(Harness.visibleWideTable(in: harness.textView)) + + harness.textView.setFrameSize( + NSSize(width: 600, height: harness.textView.frame.height) + ) + Harness.drain(mode: .eventTracking, duration: 0.03) + + #expect(Harness.visibleWideTable(in: harness.textView) == nil) + } + + @Test("Rapid width writes coalesce to the latest width per run-loop turn") + func rapidWidthWritesCoalesceToLatestWidthPerRunLoopTurn() async throws { + let harness = try Harness() + defer { harness.close() } + let recorder = EditRecorder() + NotificationCenter.default.addObserver( + recorder, + selector: #selector(EditRecorder.storageDidProcessEditing(_:)), + name: NSTextStorage.didProcessEditingNotification, + object: harness.textView.textStorage + ) + defer { NotificationCenter.default.removeObserver(recorder) } + + for width in [840.0, 780.0, 720.0, 660.0] { + harness.textView.setFrameSize( + NSSize(width: width, height: harness.textView.frame.height) + ) + } + try await Task.sleep(for: .milliseconds(50)) + + let table = try #require(Harness.renderedTable(in: harness.textView)) + #expect(recorder.count == 1) + #expect(abs(table.bounds.width - 659) <= 1) + } + + @Test("Event tracking leaves no deferred table restyles") + func eventTrackingLeavesNoDeferredTableRestyles() async throws { + let harness = try Harness() + defer { harness.close() } + let recorder = EditRecorder() + NotificationCenter.default.addObserver( + recorder, + selector: #selector(EditRecorder.storageDidProcessEditing(_:)), + name: NSTextStorage.didProcessEditingNotification, + object: harness.textView.textStorage + ) + defer { NotificationCenter.default.removeObserver(recorder) } + + let live = try #require( + harness.runInEventTracking(widths: [820, 760, 700, 640, 580]) + ) + let countAtTrackingEnd = recorder.count + try await Task.sleep(for: .milliseconds(50)) + + let settled = try #require(Harness.renderedTable(in: harness.textView)) + #expect(abs(live.bounds.width - 579) <= 1) + #expect(settled.image === live.image) + #expect(recorder.count == countAtTrackingEnd) + } }