diff --git a/CHANGELOG.md b/CHANGELOG.md index 482de45b..41bf5727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/MarkdownEngine/Parser/MarkdownAST.swift b/Sources/MarkdownEngine/Parser/MarkdownAST.swift index e35ff1fc..bf4422a3 100644 --- a/Sources/MarkdownEngine/Parser/MarkdownAST.swift +++ b/Sources/MarkdownEngine/Parser/MarkdownAST.swift @@ -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 { @@ -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: @@ -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) } diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index 88bfae79..9c6b2ee9 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -642,14 +642,11 @@ 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() } @@ -657,16 +654,18 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { 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) diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift index e76cacb2..7eeaf92d 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift @@ -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) @@ -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) @@ -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)) : "." @@ -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)) } diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+OrderedMarkers.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+OrderedMarkers.swift index a62ff7a0..0144bb19 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+OrderedMarkers.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+OrderedMarkers.swift @@ -4,11 +4,11 @@ // // Created by Luca Chen on 30.07.26. // -// Caret-crossing helper for `1.` / `1)` ordered markers. The number the editor -// SHOWS is positional (`MarkdownASTStyler`), and the raw source digits are -// revealed while the caret edits them — so the coordinator needs to know when -// the caret crosses that boundary, exactly like it already does for bullets, -// task checkboxes and thematic breaks. Rendering itself lives in the AST +// Membership helper for `1.` / `1)` ordered markers. The number the editor +// SHOWS is positional (`MarkdownASTStyler`) and is painted over the source +// digits; a SELECTION sweeping the marker reverts it to those digits, so the +// coordinator has to recognise the lines that can do it. The caret does not: +// it leaves the painted number standing. Rendering itself lives in the AST // styler; this file only answers membership. // @@ -23,38 +23,4 @@ extension MarkdownStyler { pattern: #"^([ \t]*)(\d+[.)])([ \t]+)(?!\[[ xX]\])"#, options: [.anchorsMatchLines] ) - - // MARK: Ordered Marker Membership - - /// True while the caret sits ON the digits — inside `syntax`, but never at - /// its first offset. That one offset is excluded on purpose: every - /// whole-line delete and line-join parks the caret exactly there, and - /// counting it as "editing the digits" left the surviving item showing its - /// stale literal (a 1./2./3. list read 1./1. after deleting item 2). - static func caretRevealsOrderedMarker(caret: Int, syntax: NSRange) -> Bool { - caret > syntax.location && caret < NSMaxRange(syntax) - } - - /// `<.|)>` range on `location`'s line while the caret - /// reveals it, else `nil`. Paired with ``caretRevealsOrderedMarker`` so the - /// coordinator's crossing signal and the styler's reveal cannot disagree. - static func orderedSyntaxRange(at location: Int, in text: String) -> NSRange? { - let nsText = text as NSString - let safeLoc = max(0, min(location, nsText.length)) - let lineRange = nsText.lineRange(for: NSRange(location: safeLoc, length: 0)) - let line = nsText.substring(with: lineRange) - guard let match = orderedListRegex.firstMatch( - in: line, - options: [], - range: NSRange(location: 0, length: line.utf16.count) - ) else { return nil } - let markerLineRange = match.range(at: 2) - let spacerLineRange = match.range(at: 3) - guard markerLineRange.location != NSNotFound, - spacerLineRange.location != NSNotFound else { return nil } - let syntaxStart = lineRange.location + markerLineRange.location - let syntaxEnd = lineRange.location + spacerLineRange.location + spacerLineRange.length - let syntaxRange = NSRange(location: syntaxStart, length: syntaxEnd - syntaxStart) - return caretRevealsOrderedMarker(caret: safeLoc, syntax: syntaxRange) ? syntaxRange : nil - } } diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index a7e577be..14a3ae3e 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -16,6 +16,12 @@ import AppKit extension NativeTextViewCoordinator { + /// The complete leading syntax whose mutation can change list membership, + /// indentation, or the positional numbering of following ordered items. + private static let listStructurePrefixRegex = try! NSRegularExpression( + pattern: #"^[ \t]*(?:(?:\d+[.)])|[-•*+])(?:[ \t]+\[[ xX]\])?[ \t]+"# + ) + /// Supplies a per-document `UndoManager` to the text view. /// /// AppKit reuses one `NSTextView` across every open document, so the built-in @@ -71,12 +77,18 @@ extension NativeTextViewCoordinator { public func textDidChange(_ notification: Notification) { guard let tv = notification.object as? NSTextView else { return } PerfTrace.checkpoint("didIn") + let completedTextMutation = pendingEditCount == 1 + ? pendingTextMutation + : nil + pendingTextMutation = nil // Typing means the reader is here, so an unlanded restore must not fire. pendingScrollRestoreDocumentId = nil // Before the early returns: the first keystroke must hide the placeholder. (tv as? NativeTextView)?.refreshPlaceholderVisibility() // Raw mode: display IS storage — sync the binding, skip the restyle. if configuration.rawSourceMode { + pendingEditCount = 0 + pendingEditedRange = nil guard !tv.hasMarkedText() else { return } if tv.string != lastSyncedText { let rawText = tv.string @@ -90,6 +102,9 @@ extension NativeTextViewCoordinator { bottomTextView.recalcOverscroll(for: scrollView, debugTag: "textDidChange") (scrollView as? ClampedScrollView)?.clampToInsets() } + if let completedTextMutation { + onTextMutation?(completedTextMutation) + } return } let wtActive = isWritingToolsActive @@ -109,6 +124,13 @@ extension NativeTextViewCoordinator { let docString = tv.string let fullText = docString as NSString let fullLength = fullText.length + // NSTextView's undo machinery can mutate storage without replaying + // shouldChangeTextIn. Treat an in-flight undo/redo as structural when + // it intersects an ordered run below; this preserves numbering while + // ordinary content keystrokes retain their narrow paragraph scope. + let activeUndoManager = undoManagers[documentId ?? "__default__"] + let isUndoRedo = activeUndoManager?.isUndoing == true + || activeUndoManager?.isRedoing == true guard !tv.hasMarkedText() else { return } let safeLocation = min(rawSelRange.location, fullLength) let safeSelRange = NSRange(location: safeLocation, length: 0) @@ -287,14 +309,14 @@ extension NativeTextViewCoordinator { currentActiveTokenIndices: activeTokenIndices, previousActiveTokenIndices: preEditActiveTokenIndices )) - // An ordered list numbers each item by its POSITION, so adding or - // removing an item (a line-break or indent edit) shifts every following - // number through the end of the run: restyle the whole forward run — + // An ordered list numbers each item by its POSITION, so changing its + // leading marker/indent or adding/removing an item can shift every + // following number through the end of the run: restyle forward — // list blocks joined by blank separators, stopping at the first content // block. Numbers ABOVE are unchanged and the styler's backward seed // feeds the count in, so forward-only from the edit is enough. A plain // content edit shifts no number and keeps the default paragraph scope. - let listStructureChanged = pendingListStructureEdit + let listStructureChanged = pendingListStructureEdit || isUndoRedo pendingListStructureEdit = false if listStructureChanged { let editBlocks = parsed.blocks @@ -351,6 +373,9 @@ extension NativeTextViewCoordinator { } } previousActiveTokenIndices = activeTokenIndices + if let completedTextMutation { + onTextMutation?(completedTextMutation) + } PerfTrace.end() } @@ -506,18 +531,9 @@ extension NativeTextViewCoordinator { let currentBulletSyntax = MarkdownStyler.bulletSyntaxRange(at: selLoc, in: docText) let bulletSyntaxChanged = prevBulletSyntax?.location != currentBulletSyntax?.location || prevBulletSyntax?.length != currentBulletSyntax?.length - // Ordered markers: the styler paints a POSITIONAL number over the source - // digits and reveals the raw digits while the caret edits them. Nothing - // else notices that crossing — markers aren't tokens and - // `bulletListRegex` carries no digits — so without this the line keeps - // whatever was painted last (raw `10.` stuck after editing a digit, or - // an overlay still asserting a number the run no longer has). - let prevOrderedSyntax = previousCaretLocation.flatMap { - MarkdownStyler.orderedSyntaxRange(at: $0, in: docText) - } - let currentOrderedSyntax = MarkdownStyler.orderedSyntaxRange(at: selLoc, in: docText) - let orderedSyntaxChanged = prevOrderedSyntax?.location != currentOrderedSyntax?.location - || prevOrderedSyntax?.length != currentOrderedSyntax?.length + // Ordered markers need no caret signal: their painted number does not + // depend on where the caret is. A SELECTION over one still reverts it to + // raw digits — that is the reveal-syntax span below, not a crossing. // Task syntax also reveals while a SELECTION sweeps it (styler is // selection-aware), but none of the caret-based signals above fire // when only the selection SPAN changes (shift-extend keeps the @@ -530,10 +546,10 @@ extension NativeTextViewCoordinator { let span = nsText.paragraphRange(for: clamped) for needle in ["- [", "* [", "+ ["] where nsText.range(of: needle, options: [], range: span).location != NSNotFound { return true } - // An ordered marker reveals its raw digits under a selection too, and - // its slot is kerned to the DISPLAY width — without a restyle the raw - // digits would draw into a slot sized for a different number. - return MarkdownStyler.orderedListRegex.firstMatch(in: docText, options: [], range: span) != nil + // Ordered markers are NOT in here: their painted number no longer + // depends on the selection, so a selection sweeping one has nothing + // to repaint. + return false } let selectionSpanChanged = previousSelectedRange != selRange && ((previousSelectedRange?.length ?? 0) > 0 || selRange.length > 0) @@ -545,7 +561,7 @@ extension NativeTextViewCoordinator { } else if isDragSelecting { needsRestyleAfterDrag = true } else if tokensChanged || taskSyntaxChanged || hrLineChanged || bulletSyntaxChanged - || orderedSyntaxChanged || selectionSpanChanged || needsRestyleAfterDrag { + || selectionSpanChanged || needsRestyleAfterDrag { needsRestyleAfterDrag = false // Candidates are built ONLY when a restyle actually runs — this // used to happen unconditionally on every selection change, @@ -710,6 +726,63 @@ extension NativeTextViewCoordinator { return fences.contains { windowText.contains($0.fence) } } + /// Compare the touched line's list prefix before and after a proposed + /// single-line edit. Prefix changes widen the ordered run; content-only + /// edits remain paragraph-scoped. Doubt fails closed. + func editChangesListStructure( + in text: NSString, + range: NSRange, + replacement: String + ) -> Bool { + guard range.location != NSNotFound, + range.location >= 0, + range.length >= 0 else { return true } + let (rangeEnd, overflowed) = range.location.addingReportingOverflow( + range.length + ) + guard !overflowed, rangeEnd <= text.length else { return true } + guard !replacement.utf16.contains(where: { + $0 == 0x0A || $0 == 0x0D + }) else { return true } + + let line = text.lineRange( + for: NSRange(location: range.location, length: 0) + ) + var bodyEnd = NSMaxRange(line) + while bodyEnd > line.location { + let character = text.character(at: bodyEnd - 1) + guard character == 0x0A || character == 0x0D else { break } + bodyEnd -= 1 + } + guard range.location >= line.location, + rangeEnd <= bodyEnd else { return true } + + let bodyRange = NSRange( + location: line.location, + length: bodyEnd - line.location + ) + let before = text.substring(with: bodyRange) + let after = NSMutableString(string: before) + after.replaceCharacters( + in: NSRange( + location: range.location - line.location, + length: range.length + ), + with: replacement + ) + + func prefix(in candidate: String) -> String? { + let nsCandidate = candidate as NSString + guard let match = Self.listStructurePrefixRegex.firstMatch( + in: candidate, + range: NSRange(location: 0, length: nsCandidate.length) + ) else { return nil } + return nsCandidate.substring(with: match.range) + } + + return prefix(in: before) != prefix(in: after as String) + } + /// Backtick census in O(edit window): the greedy ``` count equals /// Σ floor(runLen/3) over maximal backtick runs, so an edit only changes /// the contribution of runs it touches. `previousBacktickCount` minus the @@ -775,16 +848,22 @@ extension NativeTextViewCoordinator { // would otherwise leave the suppressed edit's descriptor behind, and the // wiki splice in textDidChange would corrupt the storage form from it. pendingEditedRange = NSRange(location: affectedCharRange.location, length: replacementString?.utf16.count ?? 0) + // A nil replacement means AppKit is changing ATTRIBUTES over that range, + // not text (data detection linkifying a phone number, Format > Font). + // Coercing it to "" would publish "this range was deleted" to a listener + // that mirrors edits — so report nothing for a change that moves no text. + pendingTextMutation = replacementString.map { + MarkdownTextMutation(range: affectedCharRange, replacement: $0) + } pendingEditCount += 1 // Pre-edit backtick window baseline for the incremental census. if affectedCharRange.location >= 0, NSMaxRange(affectedCharRange) <= preNS.length { pendingBacktickWindow = (affectedCharRange.location, affectedCharRange.length, MarkdownDetection.backtickWindowCount(in: preNS, around: affectedCharRange)) pendingExtFenceTouched = editWindowTouchesExtensionFence(in: preNS, around: affectedCharRange) - // An ordered item is added/removed only when the edit inserts or - // deletes a line break → every following number shifts. A programmatic - // sub-edit (e.g. the list-continuation re-insert) only OR-adds, so it - // can't clear the user keystroke's signal. + // A programmatic sub-edit (e.g. list continuation) only OR-adds to + // this signal, so it cannot clear the user keystroke's structural + // marker, indentation, or line-break change. let addsBreak = replacementString?.utf16.contains { $0 == 0x0A || $0 == 0x0D } ?? false let removesBreak = affectedCharRange.length > 0 && preNS.rangeOfCharacter(from: .newlines, options: [], range: affectedCharRange).location != NSNotFound @@ -793,7 +872,13 @@ extension NativeTextViewCoordinator { let addsTab = replacementString?.utf16.contains { $0 == 0x09 } ?? false let removesTab = affectedCharRange.length > 0 && preNS.rangeOfCharacter(from: CharacterSet(charactersIn: "\t"), options: [], range: affectedCharRange).location != NSNotFound + let changesListPrefix = editChangesListStructure( + in: preNS, + range: affectedCharRange, + replacement: replacementString ?? "" + ) let structural = addsBreak || removesBreak || addsTab || removesTab + || changesListPrefix pendingListStructureEdit = isProgrammaticEdit ? (pendingListStructureEdit || structural) : structural } else { pendingBacktickWindow = nil diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index 03068315..aa30264b 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -78,6 +78,7 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { var layoutDelegate: MarkdownLayoutManagerDelegate? var onLinkClick: ((String) -> Void)? var onCaretRectChange: ((CGRect) -> Void)? + var onTextMutation: ((MarkdownTextMutation) -> Void)? /// Embedder hook to build the right-click menu (the engine ships none). Gets the /// default menu + current selection range, returns the menu to show. var onBuildContextMenu: ((NSMenu, NSRange) -> NSMenu)? @@ -118,9 +119,9 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { /// extension block fence — captured in shouldChangeTextIn so a DELETED /// fence still forces the full restyle in textDidChange. var pendingExtFenceTouched = false - /// Set in shouldChangeTextIn when an edit adds/removes a line break (an - /// ordered-list item was inserted/removed → every following number shifts); - /// consumed once in textDidChange to restyle the whole ordered run. + /// Set in shouldChangeTextIn when an edit changes list-leading syntax or a + /// line break, which can shift every following ordered number; consumed + /// once in textDidChange to restyle the affected ordered run. var pendingListStructureEdit = false /// Set when the storage mutated without the census bookkeeping seeing it /// (IME composition) — forces the next census back to a full scan. @@ -148,6 +149,9 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { var wikiVerifyCounter: UInt = 0 var pendingEditedRange: NSRange? = nil + /// Exact pre-edit descriptor paired with `pendingEditedRange`. It is + /// published only when one accepted proposal produces the change event. + var pendingTextMutation: MarkdownTextMutation? /// Proposed-edit cycles since the last completed textDidChange. Exactly 1 /// means the hoisted editedRange/lengthDelta describe a single tracked /// edit and incremental fast paths may trust them. @@ -485,4 +489,3 @@ extension NSTextView { return boundingRect } } - diff --git a/Sources/MarkdownEngine/TextView/MarkdownTextMutation.swift b/Sources/MarkdownEngine/TextView/MarkdownTextMutation.swift new file mode 100644 index 00000000..1968112e --- /dev/null +++ b/Sources/MarkdownEngine/TextView/MarkdownTextMutation.swift @@ -0,0 +1,22 @@ +// +// MarkdownTextMutation.swift +// MarkdownEngine +// + +import Foundation + +/// One completed native editor mutation in UTF-16 display-text coordinates. +/// +/// `range` addresses the text before the edit and `replacement` is the exact +/// string that replaced it. MarkdownEngine reports only transitions backed by +/// one accepted native edit; multi-step smart-input transformations and +/// ambiguous composition batches are intentionally omitted. +public struct MarkdownTextMutation: Equatable, Sendable { + public let range: NSRange + public let replacement: String + + public init(range: NSRange, replacement: String) { + self.range = range + self.replacement = replacement + } +} diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index fbfbcc5c..aef9b96c 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -83,6 +83,10 @@ public struct NativeTextViewWrapper: NSViewRepresentable { /// Fires whenever the caret rect inside an active wiki-link changes, /// so embedders can position a follow-the-caret UI. public var onCaretRectChange: ((CGRect) -> Void)? + /// Reports one completed native edit in UTF-16 display-text coordinates. + /// Multi-step smart-input transformations and ambiguous composition + /// batches are omitted so embedders can treat every callback as exact. + public var onTextMutation: ((MarkdownTextMutation) -> Void)? /// Build the editor's right-click menu (the engine ships no menu). Receives the default /// NSMenu + the current selection range; return the menu to display (or unchanged). public var onBuildContextMenu: ((NSMenu, NSRange) -> NSMenu)? @@ -149,6 +153,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { onPasteImage: ((NSPasteboard) -> String?)? = nil, onLinkClick: ((String) -> Void)? = nil, onCaretRectChange: ((CGRect) -> Void)? = nil, + onTextMutation: ((MarkdownTextMutation) -> Void)? = nil, onBuildContextMenu: ((NSMenu, NSRange) -> NSMenu)? = nil, onInlineSelectionChange: ((InlineSelectionState?) -> Void)? = nil, onInlinePreviewKey: ((InlinePreviewKey) -> Bool)? = nil, @@ -174,6 +179,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { self.onPasteImage = onPasteImage self.onLinkClick = onLinkClick self.onCaretRectChange = onCaretRectChange + self.onTextMutation = onTextMutation self.onBuildContextMenu = onBuildContextMenu self.onInlineSelectionChange = onInlineSelectionChange self.onInlinePreviewKey = onInlinePreviewKey @@ -323,6 +329,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { context.coordinator.textView = textView context.coordinator.wikiLinkMetadata = initialState.metadata context.coordinator.onCaretRectChange = onCaretRectChange + context.coordinator.onTextMutation = onTextMutation context.coordinator.onBuildContextMenu = onBuildContextMenu context.coordinator.onInlineSelectionChange = onInlineSelectionChange context.coordinator.onInlinePreviewKey = onInlinePreviewKey @@ -664,6 +671,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { } context.coordinator.onCaretRectChange = onCaretRectChange + context.coordinator.onTextMutation = onTextMutation context.coordinator.onBuildContextMenu = onBuildContextMenu context.coordinator.onInlineSelectionChange = onInlineSelectionChange context.coordinator.onInlinePreviewKey = onInlinePreviewKey @@ -682,6 +690,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { ) coordinator.documentId = documentId coordinator.onPersistScrollOffset = onPersistScrollOffset + coordinator.onTextMutation = onTextMutation coordinator.restoreScrollOffset = restoreScrollOffset // Seeding documentId above means the first update pass is not a switch, so // arm the restore here or a remount would always open at the top. diff --git a/Tests/MarkdownEngineTests/ASTPipelineTests.swift b/Tests/MarkdownEngineTests/ASTPipelineTests.swift index 972160e1..796170c7 100644 --- a/Tests/MarkdownEngineTests/ASTPipelineTests.swift +++ b/Tests/MarkdownEngineTests/ASTPipelineTests.swift @@ -13,6 +13,78 @@ import Testing @Suite("Phase 2.5 — AST pipeline end-to-end") struct ASTPipelineTests { + @Test("scoped list AST contains only intersecting physical items") + func scopedListContainsOnlyIntersectingItems() throws { + let lines = (0..<2_000).map { "- [x] item \($0)\n" } + let text = lines.joined() + let ns = text as NSString + let target = ns.range(of: "- [x] item 1500") + let targetLine = ns.lineRange(for: target) + + let nodes = DocumentAST.parse(text, scopedRanges: [targetLine]) + let list = try #require(nodes.first) + guard case .list(_, let items) = list else { + Issue.record("Expected one scoped list node") + return + } + + #expect(items.map(\.range) == [targetLine]) + } + + @Test("scoped list AST normalizes overlapping and unordered scopes") + func scopedListNormalizesScopes() throws { + let text = "- one\n- two\n- three\n- four\n" + let ns = text as NSString + let second = ns.lineRange(for: ns.range(of: "- two")) + let fourth = ns.lineRange(for: ns.range(of: "- four")) + let overlappingSecond = NSRange( + location: second.location + 1, + length: second.length - 1 + ) + + let nodes = DocumentAST.parse( + text, + scopedRanges: [ + fourth, + NSRange(location: ns.length + 1, length: 1), + overlappingSecond, + second, + NSRange(location: 0, length: 0), + ] + ) + let list = try #require(nodes.first) + guard case .list(_, let items) = list else { + Issue.record("Expected one scoped list node") + return + } + + #expect(items.map(\.range) == [second, fourth]) + } + + @Test("scoped list AST rejects malformed UTF-16 ranges") + func scopedListRejectsMalformedRanges() throws { + let text = "- one\n- two\n- three\n" + let ns = text as NSString + let second = ns.lineRange(for: ns.range(of: "- two")) + + let nodes = DocumentAST.parse( + text, + scopedRanges: [ + NSRange(location: -1, length: 1), + NSRange(location: Int.max - 1, length: 4), + NSRange(location: ns.length, length: 1), + second, + ] + ) + let list = try #require(nodes.first) + guard case .list(_, let items) = list else { + Issue.record("Expected one scoped list node") + return + } + + #expect(items.map(\.range) == [second]) + } + @Test("bug 2: no inline markup tokens inside a fenced code block") func bug2InlineInsideCode() { let text = "```swift\n*not italic* `not code`\n```\n" diff --git a/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift b/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift index fc170021..e6e72ee1 100644 --- a/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift +++ b/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift @@ -17,6 +17,79 @@ struct MarkdownASTStylerTests { private let base: CGFloat = 14 private var fontName: String { NSFont.systemFont(ofSize: 14).fontName } + @MainActor + @Test("scoped styling of a continuous list emits only intersecting ranges") + func scopedContinuousListEmitsOnlyIntersectingRanges() { + _ = NSApplication.shared + let text = String( + repeating: "- [x] **fast** `native` [link](relative.md)\n", + count: 2_000 + ) + let ns = text as NSString + let target = ns.lineRange( + for: ns.range(of: "- [x] **fast**", options: .backwards) + ) + + let attrs = MarkdownASTStyler.styleAttributes( + text: text, + fontName: fontName, + fontSize: base, + scopedRanges: [target] + ) + + #expect(!attrs.isEmpty) + #expect(attrs.allSatisfy { + NSIntersectionRange($0.range, target).length > 0 + }) + } + + @MainActor + @Test("scoped list styling matches full effective attribute values") + func scopedListMatchesFullEffectiveAttributeValues() { + _ = NSApplication.shared + let text = "- plain *one*\n- [x] **done** `code`\n- [ ] [link](a.md)\n- final _four_\n" + let ns = text as NSString + let second = ns.lineRange(for: ns.range(of: "- [x]")) + let fourth = ns.lineRange(for: ns.range(of: "- final")) + let scope = [fourth, second] + let caret = second.location + 3 + let full = MarkdownASTStyler.styleAttributes( + text: text, + fontName: fontName, + fontSize: base, + caretLocation: caret + ) + let scoped = MarkdownASTStyler.styleAttributes( + text: text, + fontName: fontName, + fontSize: base, + caretLocation: caret, + scopedRanges: scope + ) + let fullStorage = NSMutableAttributedString(string: text) + let scopedStorage = NSMutableAttributedString(string: text) + TextStylingService.applyStyledRanges( + full, + paragraphs: scope, + baseAttributes: [:], + to: fullStorage + ) + TextStylingService.applyStyledRanges( + scoped, + paragraphs: scope, + baseAttributes: [:], + to: scopedStorage + ) + + for range in scope { + #expect( + fullStorage.attributedSubstring(from: range).isEqual( + to: scopedStorage.attributedSubstring(from: range) + ) + ) + } + } + /// Effective font at `pos`: the last styled range covering it that sets `.font`. private func font(in attrs: [StyledRange], at pos: Int) -> NSFont? { var result: NSFont? diff --git a/Tests/MarkdownEngineTests/MarkdownTextMutationTests.swift b/Tests/MarkdownEngineTests/MarkdownTextMutationTests.swift new file mode 100644 index 00000000..63ec7241 --- /dev/null +++ b/Tests/MarkdownEngineTests/MarkdownTextMutationTests.swift @@ -0,0 +1,103 @@ +// +// MarkdownTextMutationTests.swift +// MarkdownEngineTests +// +// Exact native edit descriptors for embedders that maintain their own +// source authority or mirror an edit into another presentation. +// + +import AppKit +import SwiftUI +import Testing +@testable import MarkdownEngine + +@MainActor +@Suite("Markdown text mutation callback") +struct MarkdownTextMutationTests { + + private func makeEditor( + _ text: String, + onTextMutation: @escaping (MarkdownTextMutation) -> Void + ) -> NativeTextView { + _ = NSApplication.shared + let wrapper = NativeTextViewWrapper( + text: .constant(text), + fontName: "SF Pro", + fontSize: 16, + onTextMutation: onTextMutation + ) + let coordinator = wrapper.makeCoordinator() + let textView = NativeTextView( + frame: NSRect(x: 0, y: 0, width: 600, height: 400) + ) + textView.isEditable = true + textView.delegate = coordinator + coordinator.textView = textView + coordinator.rebuildTextStorageAndStyle(textView, from: text) + coordinator.lastSyncedText = text + coordinator.lastComputedStorage = text + coordinator.previousDisplayLength = (text as NSString).length + return textView + } + + @Test("reports the exact accepted UTF-16 replacement") + func reportsExactAcceptedReplacement() { + var received: [MarkdownTextMutation] = [] + let textView = makeEditor("- [x] **fast** item") { + received.append($0) + } + + textView.insertText( + "0", + replacementRange: NSRange(location: 12, length: 0) + ) + + #expect(textView.string == "- [x] **fast0** item") + #expect( + received == [ + MarkdownTextMutation( + range: NSRange(location: 12, length: 0), + replacement: "0" + ) + ] + ) + } + + @Test("a proposed edit alone emits no completed mutation") + func proposedEditDoesNotEmitMutation() throws { + var received: [MarkdownTextMutation] = [] + let textView = makeEditor("alpha") { + received.append($0) + } + let coordinator = try #require( + textView.delegate as? NativeTextViewCoordinator + ) + + #expect( + coordinator.textView( + textView, + shouldChangeTextIn: NSRange(location: 5, length: 0), + replacementString: "x" + ) + ) + #expect(received.isEmpty) + } + + /// AppKit proposes attribute-only changes with a nil replacement string — + /// data detection linkifying a phone number, Format > Font. No text moves, so + /// a listener mirroring these must not be told the range was replaced. + @Test("an attribute-only change emits no mutation") + func attributeOnlyChangeEmitsNoMutation() { + var received: [MarkdownTextMutation] = [] + let text = "call 555 1234 now" + let textView = makeEditor(text) { received.append($0) } + + let affected = NSRange(location: 5, length: 8) // "555 1234" + #expect(textView.shouldChangeText(in: affected, replacementString: nil)) + textView.textStorage?.addAttribute(.link, value: URL(string: "tel:5551234")!, range: affected) + textView.didChangeText() + + #expect(textView.string == text) + #expect(received.isEmpty) + } +} diff --git a/Tests/MarkdownEngineTests/OrderedListDisplayNumberingTests.swift b/Tests/MarkdownEngineTests/OrderedListDisplayNumberingTests.swift index aa6fd75b..367629ad 100644 --- a/Tests/MarkdownEngineTests/OrderedListDisplayNumberingTests.swift +++ b/Tests/MarkdownEngineTests/OrderedListDisplayNumberingTests.swift @@ -10,8 +10,9 @@ // * the block array a SCOPED restyle sees is not the document — the text // between two scoped regions is missing, so a run can look continuous when // prose actually ended it; -// * the overlay is caret-aware, so the coordinator has to restyle when the -// caret crosses a marker — no other signal covers ordered markers. +// * unlike every other markdown construct, the overlay does NOT step aside for +// the caret or a selection — the source digit is positional, not authored, +// and revealing it renamed the item the reader was pointing at. // import AppKit @@ -132,6 +133,19 @@ struct OrderedListDisplayNumberingTests { #expect(overlays(style(text)).map(\.text) == ["2.", "3."]) // full pass agrees } + @Test("disjoint scopes inside one list reseed omitted items") + func disjointScopesInsideOneListReseedOmittedItems() { + let text = "1. one\n1. two\n1. three\n1. four\n" + let ns = text as NSString + let first = ns.lineRange(for: ns.range(of: "1. one")) + let fourth = ns.lineRange(for: ns.range(of: "1. four")) + + let painted = overlays(style(text, scoped: [first, fourth])) + + #expect(painted.map(\.loc) == [fourth.location]) + #expect(painted.map(\.text) == ["4."]) + } + // MARK: Caret /// The caret parked at the line start is where every whole-line delete and @@ -145,27 +159,129 @@ struct OrderedListDisplayNumberingTests { #expect(painted.first?.loc == 5) } - @Test("caret on the digits reveals the raw marker") - func caretOnTheDigitsRevealsTheRawMarker() { - #expect(overlays(style("1. a\n1. b", caret: 6)).isEmpty) // between `1` and `.` - #expect(overlays(style("1. a\n1. b", caret: 7)).isEmpty) // the space after `1.` - #expect(overlays(style("1. a\n1. b", caret: 8)).count == 1) // content: overlay is back + /// The source digit is not something the reader authored — it is positional, + /// and in a run written `1./1./1.` every item's source reads `1.`. Revealing + /// it under the caret meant a plain click inside the marker flipped the + /// number back to `1.`, so the caret leaves the painted number alone. + @Test("caret on the digits keeps the display number") + func caretOnTheDigitsKeepsTheDisplayNumber() { + #expect(overlays(style("1. a\n1. b", caret: 6)).map(\.text) == ["2."]) // between `1` and `.` + #expect(overlays(style("1. a\n1. b", caret: 7)).map(\.text) == ["2."]) // the space after `1.` + #expect(overlays(style("1. a\n1. b", caret: 8)).map(\.text) == ["2."]) // content + } + + /// Nor does a selection: ⌘A used to swap every marker back to its source + /// digit, so a whole list read one lower than it renders while selected. + @Test("a selection over the marker keeps the display number") + func selectionOverTheMarkerKeepsTheDisplayNumber() { + let text = "1. a\n1. b" + for selection in [NSRange(location: 5, length: 3), // just the marker + NSRange(location: 0, length: (text as NSString).length)] { // ⌘A + let painted = MarkdownASTStyler.styleAttributes( + text: text, fontName: fontName, fontSize: fontSize, + caretLocation: -1, selection: selection + ) + #expect(overlays(painted).map(\.text) == ["2."], "selection \(selection)") + } } // MARK: Coordinator wiring - /// The load-bearing half: the styler's reveal is caret-dependent, so a - /// caret move across the marker has to trigger a restyle. Nothing else - /// signals it (markers aren't tokens, the bullet regex has no digits). - @Test("caret leaving the marker restyles the line") - func caretLeavingTheMarkerRestylesTheLine() { - let (_, tv) = makeEditor("1. a\n1. b") + @Test("ordered marker edits are classified as run-affecting") + func orderedMarkerEditIsRunAffecting() { + let (coordinator, tv) = makeEditor("3. a\n1. b\n1. c") + coordinator.pendingListStructureEdit = false + + let accepted = coordinator.textView( + tv, + shouldChangeTextIn: NSRange(location: 0, length: 1), + replacementString: "5" + ) - tv.setSelectedRange(NSRange(location: 6, length: 0)) // inside the digits - #expect(overlays(in: tv).isEmpty) + #expect(accepted) + #expect(coordinator.pendingListStructureEdit) + } - tv.setSelectedRange(NSRange(location: 2, length: 0)) // away, onto line 1 - #expect(overlays(in: tv).map(\.text) == ["2."]) + @Test("leading indentation edits are classified as run-affecting") + func leadingIndentEditIsRunAffecting() { + let (coordinator, tv) = makeEditor("1. a\n1. b\n1. c") + coordinator.pendingListStructureEdit = false + + let accepted = coordinator.textView( + tv, + shouldChangeTextIn: NSRange(location: 5, length: 0), + replacementString: " " + ) + + #expect(accepted) + #expect(coordinator.pendingListStructureEdit) + } + + @Test("all leading list syntax transitions are run-affecting") + func leadingListSyntaxTransitionsAreRunAffecting() { + let cases: [(range: NSRange, replacement: String)] = [ + (NSRange(location: 1, length: 1), ")"), + (NSRange(location: 0, length: 2), "-"), + (NSRange(location: 2, length: 1), ""), + ] + + for testCase in cases { + let (coordinator, tv) = makeEditor("1. a\n1. b\n1. c") + coordinator.pendingListStructureEdit = false + + let accepted = coordinator.textView( + tv, + shouldChangeTextIn: testCase.range, + replacementString: testCase.replacement + ) + + #expect(accepted) + #expect(coordinator.pendingListStructureEdit) + } + } + + @Test("programmatic marker edits remain run-affecting") + func programmaticMarkerEditIsRunAffecting() { + let (coordinator, tv) = makeEditor("1. a\n1. b\n1. c") + coordinator.isProgrammaticEdit = true + coordinator.pendingListStructureEdit = false + + let accepted = coordinator.textView( + tv, + shouldChangeTextIn: NSRange(location: 0, length: 1), + replacementString: "3" + ) + + #expect(accepted) + #expect(coordinator.pendingListStructureEdit) + } + + @Test("list content edits stay paragraph-scoped") + func listContentEditIsNotRunAffecting() { + let (coordinator, tv) = makeEditor("1. a\n1. b\n1. c") + coordinator.pendingListStructureEdit = false + + let accepted = coordinator.textView( + tv, + shouldChangeTextIn: NSRange(location: 8, length: 1), + replacementString: "B" + ) + + #expect(accepted) + #expect(!coordinator.pendingListStructureEdit) + } + + /// The paint is caret-independent now, so moving in and out of the marker + /// must leave the rendered number untouched — including the position every + /// whole-line delete and line-join parks the caret at. + @Test("moving the caret through the marker never changes the number") + func caretThroughTheMarkerKeepsTheNumber() { + let (_, tv) = makeEditor("1. a\n1. b") + + for caret in [5, 6, 7, 8, 2] { + tv.setSelectedRange(NSRange(location: caret, length: 0)) + #expect(overlays(in: tv).map(\.text) == ["2."], "caret \(caret)") + } } /// A content keystroke shifts no number, so it keeps the default paragraph @@ -182,6 +298,63 @@ struct OrderedListDisplayNumberingTests { #expect(overlays(in: tv).map(\.text) == ["2.", "3."]) } + @Test("editing the run's starting number restyles following items") + func editingRunStartNumberRestylesFollowingItems() { + let (_, tv) = makeEditor("3. a\n1. b\n1. c") + #expect(overlays(in: tv).map(\.text) == ["4.", "5."]) + + tv.insertText("5", replacementRange: NSRange(location: 0, length: 1)) + + #expect(tv.string == "5. a\n1. b\n1. c") + #expect(overlays(in: tv).map(\.text) == ["6.", "7."]) + } + + @Test("undo and redo of a marker edit restyle following items") + func undoRedoMarkerEditRestylesFollowingItems() throws { + let (coordinator, tv) = makeEditor("3. a\n1. b\n1. c") + tv.allowsUndo = true + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + window.contentView = tv + window.makeFirstResponder(tv) + defer { window.contentView = nil } + + let undoManager = try #require(coordinator.undoManager(for: tv)) + tv.insertText("5", replacementRange: NSRange(location: 0, length: 1)) + #expect(overlays(in: tv).map(\.text) == ["6.", "7."]) + #expect(tv.undoManager === undoManager) + + // AppKit's private text undo action bypasses delegate notifications in + // a headless test process. Register an equivalent replacement action + // so undo/redo still runs through the public text-view delegate path. + undoManager.removeAllActions() + undoManager.groupsByEvent = false + var registerReplacement: ((String, String) -> Void)! + registerReplacement = { replacement, inverse in + undoManager.registerUndo(withTarget: tv) { textView in + undoManager.disableUndoRegistration() + textView.insertText(replacement, replacementRange: NSRange(location: 0, length: 1)) + undoManager.enableUndoRegistration() + registerReplacement(inverse, replacement) + } + } + undoManager.beginUndoGrouping() + registerReplacement("3", "5") + undoManager.endUndoGrouping() + + undoManager.undo() + #expect(tv.string == "3. a\n1. b\n1. c") + #expect(overlays(in: tv).map(\.text) == ["4.", "5."]) + + undoManager.redo() + #expect(tv.string == "5. a\n1. b\n1. c") + #expect(overlays(in: tv).map(\.text) == ["6.", "7."]) + } + /// End-to-end repro of the shipped-looking bug: delete the middle item and /// the survivor must renumber instead of showing its stale literal. @Test("deleting an item renumbers the survivor") @@ -194,4 +367,43 @@ struct OrderedListDisplayNumberingTests { #expect(tv.string == "1. a\n1. c") #expect(overlays(in: tv).map(\.text) == ["2."]) } + + // MARK: Scoped item runs + + /// A caret move restyles the paragraph it entered AND the one it left. Coming + /// from the content block directly above the list, the scoped list node starts + /// at a LATER item — with no item above it inside that node to count from, and + /// with the preceding block having just cleared the seed flag. The number has + /// to come back from the source, not from the item's own literal digits. + @Test("clicking from the block above into a later item keeps the number") + func clickFromBlockAboveKeepsTheNumber() { + for above in ["# H", "> quote", "**bold** text"] { + let (_, tv) = makeEditor("\(above)\n1. a\n1. b\n1. c\n") + #expect(overlays(in: tv).map(\.text) == ["2.", "3."]) + + let content = (tv.string as NSString).range(of: "c").location + tv.setSelectedRange(NSRange(location: 1, length: 0)) // into the block above + tv.setSelectedRange(NSRange(location: content, length: 0)) // into item 3's content + + #expect(overlays(in: tv).map(\.text) == ["2.", "3."], "block above: \(above)") + } + } + + /// The mirror image: a scope that keeps only the HEAD of a list block leaves + /// its tail unbuilt. Those dropped lines are content, not the blank-line + /// spacing of a loose list, so the next block must re-seed instead of + /// continuing a count that stopped early. + @Test("a scope truncating a list's tail does not miscount the next block") + func truncatedTailDoesNotMiscountTheNextBlock() { + let (_, tv) = makeEditor("1. one\n1. two\n\n1. three\n1. four\n") + #expect(overlays(in: tv).map(\.text) == ["2.", "3.", "4."]) + + // Off the digits of the second block's first item — the caret move that + // forces the restyle — into the first block's first item, leaving that + // block's remaining items outside the scope. + tv.setSelectedRange(NSRange(location: 15, length: 0)) + tv.setSelectedRange(NSRange(location: 2, length: 0)) + + #expect(overlays(in: tv).map(\.text) == ["2.", "3.", "4."]) + } } diff --git a/Tests/MarkdownEngineTests/ScopedListRestyleEquivalenceTests.swift b/Tests/MarkdownEngineTests/ScopedListRestyleEquivalenceTests.swift new file mode 100644 index 00000000..68266cb7 --- /dev/null +++ b/Tests/MarkdownEngineTests/ScopedListRestyleEquivalenceTests.swift @@ -0,0 +1,122 @@ +// +// ScopedListRestyleEquivalenceTests.swift +// MarkdownEngineTests +// +// Created by Luca Chen on 12.08.26. +// +// A scoped restyle builds only the list items its scope reached, but +// `TextStylingService.restyle` hands the SAME ranges to the styler and to +// `applyStyledRanges` — so every paragraph it resets to base attributes is a +// paragraph the styler must fully re-emit. That makes scoped-vs-full an +// equivalence, not an approximation, and ordered display numbers are the part +// that breaks first: they are positional, so an item cut off from the items +// above it has nothing left to count from. +// +// Hand-written expectations cannot cover the shapes that go wrong (they are +// combinations: which block precedes the list, which items the scope kept, +// where the run ends), so this sweeps a corpus against the full pass instead — +// every line as its own scope, and every pair of lines as the two-region scope +// a caret move produces. +// + +import AppKit +import Testing +@testable import MarkdownEngine + +@MainActor +@Suite("Scoped list restyle equivalence") +struct ScopedListRestyleEquivalenceTests { + + private let fontSize: CGFloat = 14 + private var fontName: String { NSFont.systemFont(ofSize: 14).fontName } + + /// Shapes chosen for what precedes the list (nothing / content block / blank + /// separator), how it nests, and where a scope can truncate the item run. + private static let corpus: [(name: String, text: String)] = [ + ("flat-ordered", "1. one\n1. two\n1. three\n1. four\n1. five\n"), + ("ordered-start-3", "3. a\n1. b\n1. c\n1. d\n"), + ("nested-ordered", "1. a\n 1. a1\n 1. a2\n1. b\n 1. b1\n1. c\n"), + ("mixed-markers", "1. a\n- b\n1. c\n* d\n1) e\n"), + ("task-list", "- [ ] a **bold**\n- [x] b `code`\n- [ ] c [l](a.md)\n- [x] d\n"), + ("loose-ordered", "1. a\n\n1. b\n\n1. c\n"), + ("prose-split", "1. a\n1. b\n\ntext between\n\n1. c\n1. d\n"), + ("list-then-heading", "1. a\n1. b\n# H\n1. c\n1. d\n"), + ("inline-heavy", "1. *i* **b** `c` [l](a.md) ~~s~~\n1. plain\n1. *x* **y**\n"), + ("indent-jumps", "1. a\n 1. deep\n1. b\n 1. mid\n1. c\n"), + ("no-trailing-newline", "1. a\n1. b\n1. c"), + ("blockquote-and-list", "> quote\n1. a\n1. b\n> more\n1. c\n"), + ("heading-then-nested", "# H\n1. a\n 1. a1\n 1. a2\n1. b\n"), + ("indented-list-start", " 1. a\n 1. b\n 1. c\n"), + ("prose-then-deep", "text\n1. a\n 1. deep1\n 1. deep2\n1. b\n"), + ("code-then-list", "```\nx\n```\n1. a\n1. b\n1. c\n"), + ("table-then-list", "| a | b |\n| - | - |\n1. a\n1. b\n1. c\n"), + ("two-runs", "1. a\n1. b\n# H\n1. c\n1. d\n# H2\n1. e\n1. f\n"), + ("loose-tail", "1. one\n1. two\n\n1. three\n1. four\n"), + ("loose-tail-numbered", "1. one\n2. two\n\n3. three\n4. four\n"), + ("loose-tail-long", "1. a\n1. b\n1. c\n\n1. d\n1. e\n\n1. f\n1. g\n"), + ] + + /// Through the real apply loop, so the comparison sees what storage sees. + private func applied(_ text: String, paragraphs: [NSRange], caret: Int, scoped: [NSRange]?) -> NSAttributedString { + let storage = NSMutableAttributedString(string: text) + TextStylingService.applyStyledRanges( + MarkdownASTStyler.styleAttributes( + text: text, fontName: fontName, fontSize: fontSize, + caretLocation: caret, scopedRanges: scoped + ), + paragraphs: paragraphs, + baseAttributes: [.font: NSFont(name: fontName, size: fontSize) ?? NSFont.systemFont(ofSize: fontSize)], + to: storage + ) + return storage + } + + private func lines(of text: String) -> [NSRange] { + let ns = text as NSString + var out: [NSRange] = [] + var cursor = 0 + while cursor < ns.length { + let line = ns.lineRange(for: NSRange(location: cursor, length: 0)) + out.append(line) + cursor = NSMaxRange(line) + } + return out + } + + @Test("every single-line scope matches the full pass inside that line") + func singleLineScopesMatchFullPass() { + _ = NSApplication.shared + for entry in Self.corpus { + for line in lines(of: entry.text) { + for caret in [-1, line.location + min(2, max(line.length - 1, 0))] { + let full = applied(entry.text, paragraphs: [line], caret: caret, scoped: nil) + let scoped = applied(entry.text, paragraphs: [line], caret: caret, scoped: [line]) + #expect(full.attributedSubstring(from: line).isEqual(to: scoped.attributedSubstring(from: line)), + "\(entry.name) line \(line) caret \(caret)") + } + } + } + } + + /// The shape every caret move produces: the paragraph the caret entered plus + /// the one it left. Both ends of the resulting item run can be truncated. + @Test("two-region scopes match the full pass inside both regions") + func disjointScopesMatchFullPass() { + _ = NSApplication.shared + for entry in Self.corpus { + let all = lines(of: entry.text) + guard all.count >= 3 else { continue } + for i in all.indices { + for j in all.indices where j > i + 1 { + let scope = [all[i], all[j]] + let full = applied(entry.text, paragraphs: scope, caret: -1, scoped: nil) + let scoped = applied(entry.text, paragraphs: scope, caret: -1, scoped: scope) + for region in scope { + #expect(full.attributedSubstring(from: region).isEqual(to: scoped.attributedSubstring(from: region)), + "\(entry.name) scope \(scope) region \(region)") + } + } + } + } + } +}