From 3d49bbf2cc0ea3aeda19fae1a862b2893b3fae12 Mon Sep 17 00:00:00 2001 From: Yishen Tu Date: Tue, 11 Aug 2026 18:07:31 +0800 Subject: [PATCH 1/4] perf: scope list restyles to intersecting items Contiguous list blocks previously rebuilt and reparsed every item for a paragraph-scoped edit, making normal typing scale with the whole list. Normalize edit scopes once, retain only intersecting physical items, and preserve ordered numbering by widening structural marker edits and reseeding disjoint scopes. --- CHANGELOG.md | 6 + .../MarkdownEngine/Parser/MarkdownAST.swift | 151 ++++++++++++++--- .../Styling/MarkdownASTStyler.swift | 7 + ...tiveTextViewCoordinator+TextDelegate.swift | 91 ++++++++++- .../NativeTextViewCoordinator.swift | 7 +- .../ASTPipelineTests.swift | 72 ++++++++ .../MarkdownASTStylerTests.swift | 73 +++++++++ .../OrderedListDisplayNumberingTests.swift | 154 ++++++++++++++++++ 8 files changed, 529 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 482de45b..3e4fe13a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift index e76cacb2..b9c05669 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift @@ -280,7 +280,13 @@ enum MarkdownASTStyler { contiguousEnd = NSMaxRange(block.range) switch block { case .list(_, let items): + var previousItemEnd: Int? 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 +299,7 @@ enum MarkdownASTStyler { counters[item.indent] = nil } for key in counters.keys where key > item.indent { counters[key] = nil } + previousItemEnd = NSMaxRange(item.range) } case .blank: break // blank lines keep the count (spacing, not a reset) diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index a7e577be..90d55159 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 @@ -109,6 +115,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 +300,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 @@ -710,6 +723,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 @@ -781,10 +851,9 @@ extension NativeTextViewCoordinator { 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 +862,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..2ea7a99b 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -118,9 +118,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. @@ -485,4 +485,3 @@ extension NSTextView { return boundingRect } } - 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/OrderedListDisplayNumberingTests.swift b/Tests/MarkdownEngineTests/OrderedListDisplayNumberingTests.swift index aa6fd75b..eb0a3255 100644 --- a/Tests/MarkdownEngineTests/OrderedListDisplayNumberingTests.swift +++ b/Tests/MarkdownEngineTests/OrderedListDisplayNumberingTests.swift @@ -132,6 +132,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 @@ -154,6 +167,90 @@ struct OrderedListDisplayNumberingTests { // MARK: Coordinator wiring + @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" + ) + + #expect(accepted) + #expect(coordinator.pendingListStructureEdit) + } + + @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 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). @@ -182,6 +279,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") From 79ede1bccdf8e510bf556a35bbebbf5e3c4ded4a Mon Sep 17 00:00:00 2001 From: Yishen Tu Date: Tue, 11 Aug 2026 18:25:47 +0800 Subject: [PATCH 2/4] feat: report exact native text mutations Post-styling text-storage ranges can expand a one-character edit to an entire paragraph, preventing embedders from mirroring edits incrementally. Publish a completed single-edit descriptor from the coordinator's authoritative native lifecycle so consumers can preserve the exact UTF-16 transition. --- CHANGELOG.md | 5 ++ ...tiveTextViewCoordinator+TextDelegate.swift | 16 ++++ .../NativeTextViewCoordinator.swift | 4 + .../TextView/MarkdownTextMutation.swift | 22 +++++ .../TextView/NativeTextViewWrapper.swift | 9 ++ .../MarkdownTextMutationTests.swift | 85 +++++++++++++++++++ 6 files changed, 141 insertions(+) create mode 100644 Sources/MarkdownEngine/TextView/MarkdownTextMutation.swift create mode 100644 Tests/MarkdownEngineTests/MarkdownTextMutationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e4fe13a..35f4228a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ 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. + ### Performance - Scoped restyles inside a contiguous list parse and style only intersecting items instead of rebuilding the whole list block. Marker, indentation, diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 90d55159..90bb049e 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -77,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 @@ -96,6 +102,9 @@ extension NativeTextViewCoordinator { bottomTextView.recalcOverscroll(for: scrollView, debugTag: "textDidChange") (scrollView as? ClampedScrollView)?.clampToInsets() } + if let completedTextMutation { + onTextMutation?(completedTextMutation) + } return } let wtActive = isWritingToolsActive @@ -364,6 +373,9 @@ extension NativeTextViewCoordinator { } } previousActiveTokenIndices = activeTokenIndices + if let completedTextMutation { + onTextMutation?(completedTextMutation) + } PerfTrace.end() } @@ -845,6 +857,10 @@ 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) + pendingTextMutation = MarkdownTextMutation( + range: affectedCharRange, + replacement: replacementString ?? "" + ) pendingEditCount += 1 // Pre-edit backtick window baseline for the incremental census. if affectedCharRange.location >= 0, NSMaxRange(affectedCharRange) <= preNS.length { diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index 2ea7a99b..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)? @@ -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. 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/MarkdownTextMutationTests.swift b/Tests/MarkdownEngineTests/MarkdownTextMutationTests.swift new file mode 100644 index 00000000..653565cc --- /dev/null +++ b/Tests/MarkdownEngineTests/MarkdownTextMutationTests.swift @@ -0,0 +1,85 @@ +// +// 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) + } +} From d4aef6847e4a59b5f5b100d9050d7346c52d9f94 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Wed, 12 Aug 2026 21:34:06 +0200 Subject: [PATCH 3/4] fix: re-seed ordered numbers at both ends of a scoped item run Narrowing a scoped list node to the items its scope reached leaves two holes the numbering walk cannot see, and both surface on a plain caret move: the restyle covers the caret paragraph plus the one it left, and applyStyledRanges resets exactly the region the styler is asked to re-emit, so a number it declines to paint is visibly gone. LEADING hole: the node can start at a LATER item, but previousItemEnd started nil, so nothing re-seeded. With the preceding content block having just cleared needsSeed, the item fell back to its source literal and its overlay disappeared -- `# H` above `1./1./1.`, caret from the heading into item 3, which then renders `1.` and stays wrong until that item is restyled again. TRAILING hole: contiguousEnd kept the full block range, hiding the items the scope dropped from the tail. The gap to the next list block then read as loose-list spacing, so a count that stopped early carried over and painted a WRONG number over a correct literal -- `1. one/1. two`, blank, `1. three`, caret off item 3's digits into item 1, and item 3 renders `2.`. Also stop publishing onTextMutation for attribute-only edits. AppKit proposes those with a nil replacement string (data detection linkifying a phone number, Format > Font); coercing it to "" told a listener mirroring edits that the range had been deleted, while no text moved. Tests: both caret-move repros, the attribute-only case, and a scoped-vs-full equivalence sweep over 21 list shapes -- every line as its own scope and every line pair as the two-region scope a click produces. All four fail without these changes. Co-Authored-By: Claude Opus 5 (1M context) --- .../Styling/MarkdownASTStyler.swift | 17 ++- ...tiveTextViewCoordinator+TextDelegate.swift | 11 +- .../MarkdownTextMutationTests.swift | 18 +++ .../OrderedListDisplayNumberingTests.swift | 39 ++++++ .../ScopedListRestyleEquivalenceTests.swift | 122 ++++++++++++++++++ 5 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 Tests/MarkdownEngineTests/ScopedListRestyleEquivalenceTests.swift diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift index b9c05669..2e1ce93b 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift @@ -279,8 +279,12 @@ enum MarkdownASTStyler { } contiguousEnd = NSMaxRange(block.range) switch block { - case .list(_, let items): - var previousItemEnd: Int? + 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 { @@ -301,6 +305,15 @@ enum MarkdownASTStyler { 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) case .paragraph(_, let inlines) where inlines.isEmpty: diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 90bb049e..b8d8b053 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -857,10 +857,13 @@ 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) - pendingTextMutation = MarkdownTextMutation( - range: affectedCharRange, - replacement: replacementString ?? "" - ) + // 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 { diff --git a/Tests/MarkdownEngineTests/MarkdownTextMutationTests.swift b/Tests/MarkdownEngineTests/MarkdownTextMutationTests.swift index 653565cc..63ec7241 100644 --- a/Tests/MarkdownEngineTests/MarkdownTextMutationTests.swift +++ b/Tests/MarkdownEngineTests/MarkdownTextMutationTests.swift @@ -82,4 +82,22 @@ struct MarkdownTextMutationTests { ) #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 eb0a3255..cd9c7dac 100644 --- a/Tests/MarkdownEngineTests/OrderedListDisplayNumberingTests.swift +++ b/Tests/MarkdownEngineTests/OrderedListDisplayNumberingTests.swift @@ -348,4 +348,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)") + } + } + } + } + } +} From d90986edc263a7774ceabe0378a92cfbcb1551c0 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Wed, 12 Aug 2026 22:28:09 +0200 Subject: [PATCH 4/4] fix: keep an ordered list's number under the caret and a selection The number an ordered item shows is positional; the digit in the file is not. In a run written `1./1./1.` -- the spelling that lets the renderer do the counting -- every item's source reads `1.`, so revealing it was not "show me what I am editing" but "rename the item you are pointing at": insert an item in the middle, click inside a marker below it, and the `3.` you just watched appear turned back into `1.`. Select all and every number below the insertion dropped at once. Both reveals go, and with them the reason the selection one existed. This marker was the only one the engine hid by COLOUR (`foregroundColor: .clear`), and `NSTextView.selectedTextAttributes` carries a `selectedTextColor` -- AppKit repaints every selected glyph opaque, so the source digits came back under the highlight and collided with the number painted over them. Revealing was the workaround. The marker is now hidden by SIZE like every other marker here (`hiddenMarkerFontSize`), which a selection cannot override, and the run is kerned back out to the display marker's width so the slot, the hanging indent and the highlight still measure the same thing. The painter takes the view's base font instead of the run's, or it would draw the number at 0.1pt too. Measured by rendering offscreen and counting glyph pixels (page background and highlight classified out), three items, one overlay each: colour-hidden unselected 2169 selected 2488 (+319: the digits) size-hidden unselected 2169 selected 2150 With nothing about the marker depending on the caret or the selection any more, its crossing signal in textViewDidChangeSelection has no work to do, and neither does the ordered-list branch of the selection-span probe: both are removed, along with the two membership helpers that only fed them. Editing the digits still works -- they are the characters under the overlay, and any edit to them re-runs the numbering. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 10 ++++ .../Renderer/MarkdownTextLayoutFragment.swift | 23 ++++---- .../Styling/MarkdownASTStyler.swift | 50 ++++++++++------- .../MarkdownStyler+OrderedMarkers.swift | 44 ++------------- ...tiveTextViewCoordinator+TextDelegate.swift | 25 +++------ .../OrderedListDisplayNumberingTests.swift | 53 +++++++++++++------ 6 files changed, 101 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35f4228a..41bf5727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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, 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 2e1ce93b..7eeaf92d 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift @@ -365,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)) : "." @@ -437,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 b8d8b053..14a3ae3e 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -531,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 @@ -555,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) @@ -570,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, diff --git a/Tests/MarkdownEngineTests/OrderedListDisplayNumberingTests.swift b/Tests/MarkdownEngineTests/OrderedListDisplayNumberingTests.swift index cd9c7dac..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 @@ -158,11 +159,30 @@ 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 @@ -251,18 +271,17 @@ struct OrderedListDisplayNumberingTests { #expect(!coordinator.pendingListStructureEdit) } - /// 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() { + /// 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") - tv.setSelectedRange(NSRange(location: 6, length: 0)) // inside the digits - #expect(overlays(in: tv).isEmpty) - - tv.setSelectedRange(NSRange(location: 2, length: 0)) // away, onto line 1 - #expect(overlays(in: tv).map(\.text) == ["2."]) + 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