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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
112 changes: 75 additions & 37 deletions Sources/MarkdownEngine/Renderer/WideTableOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
}
Expand All @@ -229,47 +243,71 @@ extension NativeTextView {
let host: NSView = breakout ? (superview ?? self) : self
let viewWidth = host.bounds.width

var seenSourceIDs: Set<Int> = []
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<Int> = []

// 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
Expand Down
28 changes: 25 additions & 3 deletions Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down
123 changes: 94 additions & 29 deletions Sources/MarkdownEngine/Styling/MarkdownStyler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand All @@ -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<Int>,
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<Int>,
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
Expand Down
Loading
Loading