Skip to content
Merged
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- `NativeTextViewWrapper.onTextMutation` reports exact, completed native edits
for embedders that maintain their own source authority or mirror edits into
another presentation.

### Changed
- An ordered list's painted number no longer reverts to the source digit under
the caret or a selection. The number is positional, so in a run written
`1./1./1.` a click inside a marker — or a select-all — flipped every number
below an insertion back to whatever the file happens to say. The source marker
is hidden by size now, like every other marker the engine hides: a selection
repaints selected glyphs opaque, so a colour-hidden marker came back under the
highlight and collided with the number drawn over it. The marker's
caret-crossing restyle signal went with the reveal.

### Performance
- Scoped restyles inside a contiguous list parse and style only intersecting
items instead of rebuilding the whole list block. Marker, indentation,
line-break, programmatic, and undo/redo edits still widen ordered-list runs
when downstream display numbers can change.

## [0.12.0] - 2026-08-10

### Added
Expand Down
151 changes: 131 additions & 20 deletions Sources/MarkdownEngine/Parser/MarkdownAST.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,27 +74,86 @@ enum DocumentAST {
registry: ExtensionRegistry = .empty) -> [BlockNode] {
let ns = text as NSString
let blocks = precomputedBlocks ?? BlockParser.parse(text, registry: registry)
let normalizedScopes = scopedRanges.map {
normalizeScopes($0, documentLength: ns.length)
}
// Scoped mode: skip building BlockNodes for blocks outside the edit.
// Blocks tile the document in order, so one sweep over sorted candidate
// ranges replaces scanning every candidate per block (which went
// quadratic in formula-rich documents with dozens of candidates).
let relevant: [Block]
if let scopedRanges {
let sorted = scopedRanges
.filter { $0.location != NSNotFound && $0.length > 0 }
.sorted { $0.location < $1.location }
var out: [Block] = []
let relevant: [(block: Block, scopes: [NSRange]?)]
if let normalizedScopes {
var out: [(Block, [NSRange]?)] = []
var ci = 0
for block in blocks {
while ci < sorted.count, NSMaxRange(sorted[ci]) <= block.range.location { ci += 1 }
guard ci < sorted.count else { break }
if sorted[ci].location < NSMaxRange(block.range) { out.append(block) }
while ci < normalizedScopes.count,
NSMaxRange(normalizedScopes[ci]) <= block.range.location {
ci += 1
}
guard ci < normalizedScopes.count else { break }
var intersections: [NSRange] = []
var si = ci
while si < normalizedScopes.count,
normalizedScopes[si].location < NSMaxRange(block.range) {
intersections.append(normalizedScopes[si])
si += 1
}
if !intersections.isEmpty {
out.append((block, intersections))
}
}
relevant = out
} else {
relevant = blocks
relevant = blocks.map { ($0, nil) }
}
return relevant.map { candidate in
node(
for: candidate.block,
ns: ns,
scopedRanges: candidate.scopes,
registry: registry
)
}
}

/// Reject malformed UTF-16 ranges, then merge them once so every scoped
/// consumer can use the same monotonic view of the edit region.
private static func normalizeScopes(
_ ranges: [NSRange],
documentLength: Int
) -> [NSRange] {
let sorted = ranges.compactMap { range -> NSRange? in
guard range.location != NSNotFound,
range.location >= 0,
range.length > 0 else { return nil }
let (end, overflowed) = range.location.addingReportingOverflow(
range.length
)
guard !overflowed, end <= documentLength else { return nil }
return range
}
.sorted {
$0.location == $1.location
? $0.length < $1.length
: $0.location < $1.location
}

var result: [NSRange] = []
result.reserveCapacity(sorted.count)
for range in sorted {
guard let previous = result.last else {
result.append(range)
continue
}
let previousEnd = NSMaxRange(previous)
guard range.location <= previousEnd else {
result.append(range)
continue
}
let end = max(previousEnd, NSMaxRange(range))
result[result.count - 1].length = end - previous.location
}
return relevant.map { node(for: $0, ns: ns, scopedRanges: scopedRanges, registry: registry) }
return result
}

private static func inScope(_ range: NSRange, _ scopedRanges: [NSRange]?) -> Bool {
Expand All @@ -112,7 +171,12 @@ enum DocumentAST {
case .blockquote:
return .blockquote(range: block.range, inlines: scoped ? InlineParser.parse(ns, range: block.range, registry: registry) : [])
case .list:
return list(block.range, ns, scoped: scoped, registry: registry)
return list(
block.range,
ns,
scopedRanges: scopedRanges,
registry: registry
)
case .fencedCode:
return .codeBlock(range: block.range)
case .blockLatex:
Expand Down Expand Up @@ -187,15 +251,62 @@ enum DocumentAST {
inlines: scoped ? InlineParser.parse(ns, range: contentRange, registry: registry) : [])
}

/// Split a list block into one `ListItem` per physical line.
private static func list(_ range: NSRange, _ ns: NSString, scoped: Bool = true, registry: ExtensionRegistry = .empty) -> BlockNode {
/// Split a list block into physical items. Scoped passes derive their lines
/// directly from the normalized scopes instead of walking the whole block.
private static func list(
_ range: NSRange,
_ ns: NSString,
scopedRanges: [NSRange]? = nil,
registry: ExtensionRegistry = .empty
) -> BlockNode {
let lineRanges: [NSRange]
if let scopedRanges {
var selected: [NSRange] = []
for scope in scopedRanges {
let intersection = NSIntersectionRange(scope, range)
guard intersection.length > 0 else { continue }
let expanded = NSIntersectionRange(
ns.lineRange(for: intersection),
range
)
var cursor = expanded.location
let end = NSMaxRange(expanded)
while cursor < end {
let line = NSIntersectionRange(
ns.lineRange(
for: NSRange(location: cursor, length: 0)
),
range
)
if line.length > 0, selected.last != line {
selected.append(line)
}
let next = NSMaxRange(line)
guard next > cursor else { break }
cursor = next
}
}
lineRanges = selected
} else {
var all: [NSRange] = []
var cursor = range.location
let end = NSMaxRange(range)
while cursor < end {
let line = ns.lineRange(
for: NSRange(location: cursor, length: 0)
)
all.append(line)
cursor = NSMaxRange(line)
}
lineRanges = all
}

var items: [ListItem] = []
var cursor = range.location
let end = NSMaxRange(range)
while cursor < end {
let line = ns.lineRange(for: NSRange(location: cursor, length: 0))
items.append(listItem(line, ns, scoped: scoped, registry: registry))
cursor = NSMaxRange(line)
items.reserveCapacity(lineRanges.count)
for line in lineRanges {
items.append(
listItem(line, ns, scoped: true, registry: registry)
)
}
return .list(range: range, items: items)
}
Expand Down
23 changes: 11 additions & 12 deletions Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -642,31 +642,30 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {
/// Paint the whole display marker "N." (`.orderedMarker` value) over the
/// hidden source marker (digits + dot, cleared by the styler as one unit and
/// kerned to the display width so any digit count aligns and content/wrapped
/// lines hang at that width). Draws the raw source marker instead while the
/// line is selected, so selection reveals the literal digits.
/// lines hang at that width). A selection does not switch this back to the
/// source digits: the number is positional, and swapping it under ⌘A made
/// every item below an insertion read one lower than it renders.
private func drawOrderedMarkers(at point: CGPoint, in context: CGContext) {
guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return }
let selectionRanges: [NSRange] = {
guard let tv = textLayoutManager?.textContainer?.textView else { return [] }
return tv.selectedRanges.map { $0.rangeValue }.filter { $0.length > 0 }
}()

NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)

let theme = (textLayoutManager?.textContainer?.textView as? NativeTextView)?
.configuration.theme ?? .default
let storageString = ts.string as NSString

ts.enumerateAttribute(.orderedMarker, in: range, options: []) { [weak self] value, attrRange, _ in
guard let self, let number = value as? String else { return }
guard let pos = self.drawPosition(forDocumentCharAt: attrRange.location, point: point) else { return }
let font = (ts.attribute(.font, at: attrRange.location, effectiveRange: nil) as? NSFont)
?? (self.textLayoutManager?.textContainer?.textView?.font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize))
let isSelected = selectionRanges.contains(where: { NSIntersectionRange($0, attrRange).length > 0 })
let raw = storageString.substring(with: attrRange)
let glyph = (isSelected ? raw : number) as NSString
// The view's base font, NOT the run's: the source marker carries the
// near-zero hidden-marker font that keeps it invisible under a
// selection, and drawing the number at 0.1pt would hide it too.
let textView = self.textLayoutManager?.textContainer?.textView
let font = (textView as? NativeTextView)?.baseFont
?? textView?.font
?? NSFont.systemFont(ofSize: NSFont.systemFontSize)
let glyph = number as NSString
let glyphAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: theme.bodyText]
let topY = pos.baselineY - font.ascender
glyph.draw(at: CGPoint(x: pos.x, y: topY), withAttributes: glyphAttrs)
Expand Down
72 changes: 52 additions & 20 deletions Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,18 @@ enum MarkdownASTStyler {
}
contiguousEnd = NSMaxRange(block.range)
switch block {
case .list(_, let items):
case .list(let listRange, let items):
// A scoped node carries only the items the scope reached, so the
// hole check has to bound BOTH ends of the item run against the
// block, not just the space between two materialized items: seeded
// with the block's start here, closed against its end below.
var previousItemEnd: Int? = listRange.location
for item in items {
if let previousItemEnd,
item.range.location > previousItemEnd {
counters = [:]
needsSeed = true
}
if item.ordered, let literal = item.number {
if needsSeed {
counters = seedOrderedCounters(above: item.marker.location, in: ns)
Expand All @@ -293,6 +303,16 @@ enum MarkdownASTStyler {
counters[item.indent] = nil
}
for key in counters.keys where key > item.indent { counters[key] = nil }
previousItemEnd = NSMaxRange(item.range)
}
// Items the scope dropped from the TAIL are not "already counted".
// Leaving contiguousEnd at the block's end hides them, so the next
// block sees only the blank separator, reads it as loose-list
// spacing, and carries a short count into a fresh run. Ending the
// stretch at the last materialized item turns them back into the
// content hole they are.
if let previousItemEnd, previousItemEnd < NSMaxRange(listRange) {
contiguousEnd = previousItemEnd
}
case .blank:
break // blank lines keep the count (spacing, not a reset)
Expand Down Expand Up @@ -345,17 +365,17 @@ enum MarkdownASTStyler {
// An ordered item whose displayed number differs from its source digit
// gets its WHOLE marker overlaid (below); the hanging indent must then
// measure the DISPLAY marker so wrapped lines align at any digit count.
// False while the caret reveals the marker (edit at raw width) and for
// tasks (the checkbox branch owns those).
let orderedSyntax = NSRange(location: item.marker.location,
length: item.contentRange.location - item.marker.location)
// Also off while the marker is inside a selection: the painter reveals the
// raw source digits there, so the slot must revert to raw width (else a
// kerned slot leaves a gap/overlap over the raw digits).
// Off for tasks (the checkbox branch owns those).
//
// Neither the caret nor a selection takes the overlay down. Every other
// markdown construct reveals its source under one, but an ordered
// marker's source digit is the one thing the reader never authored: it
// is positional, and a run written `1./1./1.` would flip a number back
// to `1.` on a plain click or a ⌘A. The digits stay hidden and the
// painter keeps drawing the display number under the selection
// highlight, which is sized to the same kerned slot.
let orderedOverlayActive = item.ordered && item.checkbox == nil && item.number != nil
&& displayNumber != nil && displayNumber != item.number
&& !MarkdownStyler.caretRevealsOrderedMarker(caret: ctx.caret, syntax: orderedSyntax)
&& !ctx.selectionIntersects(orderedSyntax)
// Keep the source punctuation (`.` or `)`) when overlaying, so a paren list stays a paren list.
let orderedPunct = orderedOverlayActive && item.marker.length > 0
? ctx.ns.substring(with: NSRange(location: NSMaxRange(item.marker) - 1, length: 1)) : "."
Expand Down Expand Up @@ -417,20 +437,32 @@ enum MarkdownASTStyler {
} else if orderedOverlayActive, let displayNumber {
// Hide the ENTIRE source marker (digits + dot) as one unit and paint
// the whole display marker "N." over it, so the dot travels with the
// digits. Kern the slot to the display marker's width (horizontal
// only — a scaled font would inflate the marker ascent and push the
// content baseline down under the pinned line height); spread across
// all marker chars so every glyph advance stays positive even when
// the number shrinks (10 → 9).
let sourceW = (ctx.ns.substring(with: item.marker) as NSString)
.size(withAttributes: [.font: ctx.baseFont]).width
// digits.
//
// Hidden by SIZE, like every other marker this engine hides, not by a
// clear colour: NSTextView.selectedTextAttributes carries a
// `selectedTextColor`, so it repaints every selected glyph opaque —
// a colour-hidden marker comes back under the highlight and collides
// with the number painted over it. A shrunken run cannot be
// repainted into visibility. The colour stays as a second line of
// defence against sub-pixel residue at extreme zoom.
//
// Kern that near-zero run back out to the display marker's width so
// the slot, the hanging indent and the selection highlight all
// measure the same thing. Horizontal only — a scaled-UP font would
// inflate the marker ascent and push the content baseline down under
// the pinned line height.
let hiddenW = (ctx.ns.substring(with: item.marker) as NSString)
.size(withAttributes: [.font: ctx.inlineMarkerFont]).width
let displayW = ("\(displayNumber)\(orderedPunct)" as NSString)
.size(withAttributes: [.font: ctx.baseFont]).width
var markerAttrs: [NSAttributedString.Key: Any] = [
.orderedMarker: "\(displayNumber)\(orderedPunct)", .foregroundColor: NSColor.clear,
.orderedMarker: "\(displayNumber)\(orderedPunct)",
.foregroundColor: NSColor.clear,
.font: ctx.inlineMarkerFont,
]
if abs(displayW - sourceW) > 0.01 {
markerAttrs[.kern] = (displayW - sourceW) / CGFloat(max(1, item.marker.length))
if abs(displayW - hiddenW) > 0.01 {
markerAttrs[.kern] = (displayW - hiddenW) / CGFloat(max(1, item.marker.length))
}
attrs.append((item.marker, markerAttrs))
}
Expand Down
Loading
Loading