diff --git a/Sources/PicoMarkdownView/Renderer/MarkdownAttributeBuilder.swift b/Sources/PicoMarkdownView/Renderer/MarkdownAttributeBuilder.swift index a882370..1d020a7 100644 --- a/Sources/PicoMarkdownView/Renderer/MarkdownAttributeBuilder.swift +++ b/Sources/PicoMarkdownView/Renderer/MarkdownAttributeBuilder.swift @@ -72,7 +72,7 @@ actor MarkdownAttributeBuilder { case .listItem(let ordered, let index, let task): return await renderListItem(snapshot: snapshot, ordered: ordered, index: index, task: task, previousBlockKind: previousBlockKind) case .blockquote: - return await renderBlockquote(snapshot: snapshot) + return await renderBlockquote(snapshot: snapshot, previousBlockKind: previousBlockKind) case .fencedCode: if let mermaid = await renderMermaidFenceIfAvailable(snapshot: snapshot, previousBlockKind: previousBlockKind) { return mermaid @@ -399,6 +399,13 @@ actor MarkdownAttributeBuilder { let inlineImages = collectImages(from: runs, blockID: snapshot.id, counter: &imageIndex) let body = await renderInline(runs, font: bodyFont) trimLeadingWhitespace(in: body) + // A finalized list line stores a trailing "\n" run. It normally + // coalesces into the plain text and is sanitized to a space, but when + // the item ends in a styled span (inline code, bold, link) the runs + // can't merge and the bare "\n" survives — combined with the "\n" + // terminator appended below it rendered as an empty paragraph + // (a full blank line between bullets). Trim it here. + trimTrailingNewlines(in: body) let bulletPrefix = bulletText + " " let rendered = NSMutableAttributedString(string: bulletPrefix, attributes: [.font: bodyFont]) @@ -702,49 +709,80 @@ actor MarkdownAttributeBuilder { } } - private func renderBlockquote(snapshot: BlockSnapshot) async -> RenderedContentResult { + private func trimTrailingNewlines(in attributedString: NSMutableAttributedString) { + let newline: unichar = 0x0A + while attributedString.length > 0 { + let lastIndex = attributedString.length - 1 + guard attributedString.mutableString.character(at: lastIndex) == newline else { break } + attributedString.deleteCharacters(in: NSRange(location: lastIndex, length: 1)) + } + } + + + private func renderBlockquote(snapshot: BlockSnapshot, previousBlockKind: BlockKind? = nil) async -> RenderedContentResult { var imageIndex = 0 let bodyRuns = sanitizeInlineRuns(snapshot.inlineRuns ?? [], kind: snapshot.kind) + // Container-only parents (e.g. the implicit level-1 block that + // `>> nested` opens) render nothing: their children draw the bars for + // every enclosing level themselves, so emitting a newline here would + // show up as a stray blank quote line above the nested content. + // Atomic payloads (images, math) count as content even when their + // text — e.g. an empty alt — is blank. + let hasOwnContent = bodyRuns.contains { run in + run.image != nil || run.math != nil || + !run.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + if !hasOwnContent && !snapshot.childIDs.isEmpty { + return RenderedContentResult(attributed: AttributedString(), + table: nil, + listItem: nil, + blockquote: nil, + math: nil, + images: [], + codeBlock: nil) + } let inlineImages = collectImages(from: bodyRuns, blockID: snapshot.id, counter: &imageIndex) let body = await renderInline(bodyRuns, font: bodyFont) - let paragraphStyle = makeBlockquoteParagraphStyle() - let lineColor = blockquoteColor.withAlphaComponent(0.6) + // Trailing newline runs that survive styled spans would otherwise + // combine with the terminator below into a blank quoted line. Only + // newlines: trailing spaces can be real content (e.g. a code span + // ending in a space). + trimTrailingNewlines(in: body) + // Nested quotes arrive as child blocks (depth 1, 2, …). The bars are + // NOT characters: the range is marked with the quote level + bar + // color and indented past a leading gutter; the text views draw one + // continuous vertical bar per level there (see + // BlockquoteBarDecoration.swift). Because the attribute also covers + // the trailing newline, adjacent quote blocks merge into one + // uninterrupted bar. Suppress the inter-paragraph gap between + // adjacent quote blocks so a nested quote reads as one quote body. + let level = snapshot.depth + 1 + let followsBlockquote = previousBlockKind == .blockquote + let paragraphStyle = makeBlockquoteParagraphStyle(level: level, + spacingBefore: followsBlockquote ? 0 : 4) let textColor = PlatformColor.rendererLabel - let prefixAttributes: [NSAttributedString.Key: Any] = [ - .font: bodyFont, - .foregroundColor: lineColor, - .paragraphStyle: paragraphStyle - ] - let bodyAttributes: [NSAttributedString.Key: Any] = [ .font: bodyFont, .foregroundColor: textColor, .paragraphStyle: paragraphStyle ] - let result = NSMutableAttributedString(string: "│ ", attributes: prefixAttributes) let styledBody = NSMutableAttributedString(attributedString: body) if styledBody.length > 0 { styledBody.addAttributes(bodyAttributes, range: NSRange(location: 0, length: styledBody.length)) } - result.append(styledBody) - - let mutableString = result.mutableString - let prefixLength = ("│ " as NSString).length - var searchLocation = prefixLength - while searchLocation < mutableString.length { - let range = mutableString.range(of: "\n", options: [], range: NSRange(location: searchLocation, length: mutableString.length - searchLocation)) - if range.location == NSNotFound { break } - let insertLocation = range.location + range.length - result.insert(NSAttributedString(string: "│ ", attributes: prefixAttributes), at: insertLocation) - searchLocation = insertLocation + prefixLength - } - result.append(NSAttributedString(string: "\n", attributes: prefixAttributes)) - result.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: result.length)) + let result = NSMutableAttributedString(attributedString: styledBody) + result.append(NSAttributedString(string: "\n", attributes: bodyAttributes)) + result.addAttributes([ + .picoBlockquoteLevel: level, + .picoBlockquoteBarColor: blockquoteColor.withAlphaComponent(0.6) + ], range: NSRange(location: 0, length: result.length)) - return RenderedContentResult(attributed: AttributedString(result), + // The plain AttributedString initializer drops the custom keys — + // convert through the pico scope so the bar attributes survive. + return RenderedContentResult(attributed: AttributedString.picoConverted(from: result), table: nil, listItem: nil, blockquote: RenderedBlockquote(content: AttributedString(styledBody)), @@ -753,13 +791,14 @@ actor MarkdownAttributeBuilder { codeBlock: nil) } - private func makeBlockquoteParagraphStyle() -> NSMutableParagraphStyle { + private func makeBlockquoteParagraphStyle(level: Int, spacingBefore: CGFloat = 4) -> NSMutableParagraphStyle { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineBreakMode = .byWordWrapping - paragraphStyle.firstLineHeadIndent = 0 - paragraphStyle.headIndent = 0 + let indent = BlockquoteBarMetrics.textIndent(level: level) + paragraphStyle.firstLineHeadIndent = indent + paragraphStyle.headIndent = indent paragraphStyle.paragraphSpacing = 8 - paragraphStyle.paragraphSpacingBefore = 4 + paragraphStyle.paragraphSpacingBefore = spacingBefore return paragraphStyle } diff --git a/Sources/PicoMarkdownView/Renderer/MarkdownRenderer.swift b/Sources/PicoMarkdownView/Renderer/MarkdownRenderer.swift index c88b79c..e897505 100644 --- a/Sources/PicoMarkdownView/Renderer/MarkdownRenderer.swift +++ b/Sources/PicoMarkdownView/Renderer/MarkdownRenderer.swift @@ -134,6 +134,13 @@ actor MarkdownRenderer { case .blockStarted(let id, _, let position): await insertBlock(id: id, at: position) mutated = true + // A new child changes its parent's snapshot (childIDs), and + // some renders depend on children — e.g. container-only quote + // parents render nothing. Refresh the parent so its cached + // render doesn't go stale mid-stream. + if let parentID = await snapshotProvider(id).parentID { + _ = await refreshBlock(id: parentID) + } case .runsAppended(let id, _), .codeAppended(let id, _), .tableHeaderConfirmed(let id), diff --git a/Sources/PicoMarkdownView/Renderer/PicoAttributeScope.swift b/Sources/PicoMarkdownView/Renderer/PicoAttributeScope.swift new file mode 100644 index 0000000..f4ac4c2 --- /dev/null +++ b/Sources/PicoMarkdownView/Renderer/PicoAttributeScope.swift @@ -0,0 +1,77 @@ +import Foundation + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// Typed keys for PicoMarkdownView's custom attributes. +/// +/// `AttributedString`'s plain conversion initializers silently drop any +/// `NSAttributedString.Key` that isn't part of a registered attribute scope. +/// The rendering pipeline converts between `NSAttributedString` and +/// `AttributedString` at its seams, so every conversion that must keep these +/// attributes goes through the `picoConverted(from:)` helpers below. + +/// 1-based blockquote nesting level (see `BlockquoteBarDecoration`). +enum PicoBlockquoteLevelAttribute: ObjectiveCConvertibleAttributedStringKey { + typealias Value = Int + typealias ObjectiveCValue = NSNumber + static let name = NSAttributedString.Key.picoBlockquoteLevel.rawValue + + static func objectiveCValue(for value: Int) throws -> NSNumber { + NSNumber(value: value) + } + + static func value(for object: NSNumber) throws -> Int { + object.intValue + } +} + +/// Platform color for drawn blockquote bars. +enum PicoBlockquoteBarColorAttribute: ObjectiveCConvertibleAttributedStringKey { + typealias Value = MarkdownColor + typealias ObjectiveCValue = MarkdownColor + static let name = NSAttributedString.Key.picoBlockquoteBarColor.rawValue + + static func objectiveCValue(for value: MarkdownColor) throws -> MarkdownColor { + value + } + + static func value(for object: MarkdownColor) throws -> MarkdownColor { + object + } +} + +extension AttributeScopes { + /// PicoMarkdownView's attribute scope: the custom keys plus the platform + /// and Foundation scopes, so scoped conversions keep standard attributes + /// (fonts, colors, paragraph styles, links, attachments) as well. + struct PicoMarkdownAttributes: AttributeScope { + let blockquoteLevel: PicoBlockquoteLevelAttribute + let blockquoteBarColor: PicoBlockquoteBarColorAttribute + #if canImport(UIKit) + let uiKit: UIKitAttributes + #elseif canImport(AppKit) + let appKit: AppKitAttributes + #endif + let foundation: FoundationAttributes + } + + var picoMarkdown: PicoMarkdownAttributes.Type { PicoMarkdownAttributes.self } +} + +extension AttributedString { + /// Conversion that preserves PicoMarkdownView's custom attributes. + static func picoConverted(from attributed: NSAttributedString) -> AttributedString { + (try? AttributedString(attributed, including: \.picoMarkdown)) ?? AttributedString(attributed) + } +} + +extension NSAttributedString { + /// Conversion that preserves PicoMarkdownView's custom attributes. + static func picoConverted(from content: AttributedString) -> NSAttributedString { + (try? NSAttributedString(content, including: \.picoMarkdown)) ?? NSAttributedString(content) + } +} diff --git a/Sources/PicoMarkdownView/Tokenizer/BlockStateMachine.swift b/Sources/PicoMarkdownView/Tokenizer/BlockStateMachine.swift index d5f342c..71689f8 100644 --- a/Sources/PicoMarkdownView/Tokenizer/BlockStateMachine.swift +++ b/Sources/PicoMarkdownView/Tokenizer/BlockStateMachine.swift @@ -35,6 +35,10 @@ struct StreamingParser { var pendingSameLineClose: Bool = false var preventNextCoalesce: Bool = false var sawBlankInSubContext: Bool = false + /// 1-based nesting level for `.blockquote` contexts (`>` = 1, + /// `>>` = 2, …); 0 for every other kind. The blockquote analogue of + /// `listIndent`. + var blockquoteLevel: Int = 0 } private struct TableState { @@ -85,6 +89,8 @@ struct StreamingParser { private struct BlockquoteInfo { var prefixLength: Int + /// Number of `>` markers in the prefix — the requested quote depth. + var markerCount: Int } private var nextID: BlockID = 1 @@ -259,7 +265,7 @@ struct StreamingParser { } if let quote = detectBlockquote(lineBuffer) { - openInlineBlock(kind: .blockquote, prefixToStrip: quote.prefixLength) + openBlockquotes(from: 0, to: quote.markerCount, prefixLength: quote.prefixLength) emittedCount = min(lineBuffer.count, quote.prefixLength) lineAnalyzed = true return @@ -334,7 +340,7 @@ struct StreamingParser { } if let quote = detectBlockquote(lineBuffer) { closeCurrentBlock() - openInlineBlock(kind: .blockquote, prefixToStrip: quote.prefixLength) + openBlockquotes(from: 0, to: quote.markerCount, prefixLength: quote.prefixLength) emittedCount = min(lineBuffer.count, quote.prefixLength) lineAnalyzed = true return @@ -469,7 +475,7 @@ struct StreamingParser { } case .blockquote: if let mathOpen = detectDisplayMathOpening(lineBuffer) { - closeCurrentBlock() + closeBlockquoteContexts() let closeAfterLine = mathOpen.closesOnSameLine openDisplayMathBlock(marker: mathOpen.marker, closing: mathOpen.closing, @@ -481,8 +487,21 @@ struct StreamingParser { return } if let quote = detectBlockquote(lineBuffer) { - ctx.linePrefixToStrip = quote.prefixLength - setCurrentBlock(ctx) + if quote.markerCount > ctx.blockquoteLevel { + // More `>` markers than open quote levels: open nested + // child blockquotes (deepening is monotonic within a + // line, so acting on a partial prefix is safe). + openBlockquotes(from: ctx.blockquoteLevel, + to: quote.markerCount, + prefixLength: quote.prefixLength) + } else { + // Same or fewer markers: lazy continuation of the + // deepest open quote (CommonMark: a shallower-marked + // line continues the open paragraph; it does not + // close inner quotes). + ctx.linePrefixToStrip = quote.prefixLength + setCurrentBlock(ctx) + } if emittedCount < quote.prefixLength { emittedCount = min(lineBuffer.count, quote.prefixLength) } @@ -531,6 +550,14 @@ struct StreamingParser { var context = ctx let sourceLine: String = includeTerminatingNewline ? lineBuffer + "\n" : lineBuffer guard emittedCount < sourceLine.count else { return } + // Marker-only quote lines (`>`, `> `) are paragraph separators, not + // content: never emit their leftover whitespace (or a hard-break + // newline from trailing spaces). While the line may still grow this + // only defers — if text follows, the full delta is emitted then. + if case .blockquote = context.kind, + isQuoteMarkerOnlyLine(lineBuffer.trimmingCharacters(in: .whitespaces)) { + return + } // When the line buffer still looks like an incomplete block-level // construct (list marker, heading, fence, etc.), defer emission so // marker characters don't leak into the current block's content. @@ -640,8 +667,15 @@ struct StreamingParser { trimTrailingSpace(for: &ctx) setCurrentBlock(ctx) } - if isBlank || force { - closeCurrentBlock() + if isBlank || force || isQuoteMarkerOnlyLine(trimmed) { + // A `>` line with no content is a blank line inside the + // quote: it ends the paragraph, so close the quote stack + // now. The next quote line reopens at its own marker + // depth (that is how `> back to level one` exits a nested + // quote and how `>` separators split quote paragraphs); + // an unquoted line starts a normal paragraph instead of + // lazily continuing the old quote. + closeBlockquoteContexts() } else { appendToCurrent("\n") } @@ -1025,6 +1059,38 @@ struct StreamingParser { _ = popBlock() } + /// Opens nested blockquote contexts from `currentLevel` (exclusive) + /// through `targetLevel` (inclusive), so `>>` / `> > >` markers produce + /// child blocks — mirroring how deeper list items push nested contexts. + /// The assembler derives `parentID`/`depth` from the open-block stack. + private mutating func openBlockquotes(from currentLevel: Int, to targetLevel: Int, prefixLength: Int) { + var level = currentLevel + while level < targetLevel { + level += 1 + openInlineBlock(kind: .blockquote, prefixToStrip: prefixLength) + if var opened = currentBlock { + opened.blockquoteLevel = level + setCurrentBlock(opened) + } + } + } + + /// Closes the current context and any enclosing blockquote contexts. + /// Blank lines (and interrupting blocks) end the entire quote stack. + private mutating func closeBlockquoteContexts() { + closeCurrentBlock() + while let remaining = currentBlock, case .blockquote = remaining.kind { + closeCurrentBlock() + } + } + + /// True for lines that consist only of `>` markers and whitespace — + /// a blank line *inside* a blockquote (paragraph separator). + private func isQuoteMarkerOnlyLine(_ trimmed: String) -> Bool { + guard trimmed.contains(">") else { return false } + return trimmed.allSatisfy { $0 == ">" || $0 == " " || $0 == "\t" } + } + private mutating func openInlineBlock(kind: BlockKind, prefixToStrip: Int = 0) { let context = BlockContext( id: nextID, @@ -1366,36 +1432,37 @@ struct StreamingParser { private func detectBlockquote(_ line: String) -> BlockquoteInfo? { var prefixLength = 0 + var markerCount = 0 + var pendingWhitespace = 0 var index = line.startIndex - var sawMarker = false - var consumedTrailingSpace = false while index < line.endIndex { let character = line[index] - if character == " " || character == "\t" { - if sawMarker { - if consumedTrailingSpace { - break - } - consumedTrailingSpace = true - prefixLength += 1 - index = line.index(after: index) - } else { + if character == ">" { + // Between markers, CommonMark allows the previous marker's + // optional trailing space plus up to three more spaces of + // indentation before a nested `>` (`> > nested` nests; + // five spaces make the inner marker literal content). + if markerCount > 0 && pendingWhitespace > 3 { break } + prefixLength += pendingWhitespace + 1 + pendingWhitespace = 0 + markerCount += 1 + index = line.index(after: index) + // One optional space (or tab) belongs to the marker itself. + if index < line.endIndex, line[index] == " " || line[index] == "\t" { prefixLength += 1 index = line.index(after: index) } - } else if character == ">" { - sawMarker = true - consumedTrailingSpace = false - prefixLength += 1 + } else if character == " " || character == "\t" { + pendingWhitespace += 1 index = line.index(after: index) } else { break } } - guard sawMarker else { return nil } - return BlockquoteInfo(prefixLength: prefixLength) + guard markerCount > 0 else { return nil } + return BlockquoteInfo(prefixLength: prefixLength, markerCount: markerCount) } private func listContinuationPrefixLength(_ line: String, currentIndent: Int) -> Int { diff --git a/Sources/PicoMarkdownView/Views/BlockquoteBarDecoration.swift b/Sources/PicoMarkdownView/Views/BlockquoteBarDecoration.swift new file mode 100644 index 0000000..6f9e568 --- /dev/null +++ b/Sources/PicoMarkdownView/Views/BlockquoteBarDecoration.swift @@ -0,0 +1,202 @@ +import Foundation + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +extension NSAttributedString.Key { + /// 1-based blockquote nesting level. The text views draw one vertical + /// bar per level in the leading gutter of ranges carrying this attribute + /// (the bars are drawn, not characters — they don't participate in + /// selection or copy). + static let picoBlockquoteLevel = NSAttributedString.Key("picoBlockquoteLevel") + + /// Platform color for the drawn blockquote bars. Dynamic colors adapt + /// to light/dark automatically at draw time. + static let picoBlockquoteBarColor = NSAttributedString.Key("picoBlockquoteBarColor") +} + +/// Shared geometry for drawn blockquote bars. The renderer reserves a text +/// gutter with these metrics; the views draw bars into it. +enum BlockquoteBarMetrics { + /// Width of each vertical bar. + static let barWidth: CGFloat = 3 + /// Horizontal distance between the leading edges of successive bars. + static let levelStep: CGFloat = 12 + /// Gap between the deepest bar and the start of the text. + static let textGap: CGFloat = 9 + + /// Head indent that clears the bars for a quote at `level`. + static func textIndent(level: Int) -> CGFloat { + CGFloat(max(level - 1, 0)) * levelStep + barWidth + textGap + } + + /// Leading x-offset of the bar for `barIndex` (0-based). + static func barOffset(barIndex: Int) -> CGFloat { + CGFloat(barIndex) * levelStep + } +} + +/// Draws vertical quote bars. Bar color resolves against the current drawing +/// appearance, so dynamic colors follow light/dark. +enum BlockquoteBarDrawer { + static func drawBars(level: Int, color: MarkdownColor, in rect: CGRect) { + guard level > 0, rect.height > 0 else { return } + for index in 0.. top else { return } + color.setFill() + let barRect = CGRect(x: x + BlockquoteBarMetrics.barOffset(barIndex: index), + y: top, + width: BlockquoteBarMetrics.barWidth, + height: bottom - top) + #if canImport(UIKit) + UIBezierPath(rect: barRect).fill() + #else + NSBezierPath(rect: barRect).fill() + #endif + } + + static var fallbackColor: MarkdownColor { + #if canImport(UIKit) + return .separator + #else + return .separatorColor + #endif + } +} + +/// TextKit 1 hook: draws blockquote bars behind the text. Used by the iOS +/// TextKit 1 view and both macOS views (the macOS "TextKit 2" view runs on +/// an `NSLayoutManager` as well). +final class BlockquoteBarLayoutManager: NSLayoutManager { + private struct BarSegment { + var level: Int + var color: MarkdownColor? + var range: NSRange + var rect: CGRect + } + + override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) { + super.drawBackground(forGlyphRange: glyphsToShow, at: origin) + guard let storage = textStorage, storage.length > 0 else { return } + + // Expand the scan by one attribute run on each side so bars can be + // bridged across paragraph gaps at the edges of the drawn region. + let charRange = characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil) + var scanStart = min(charRange.location, storage.length - 1) + if scanStart > 0 { + var effective = NSRange(location: 0, length: 0) + _ = storage.attribute(.picoBlockquoteLevel, at: scanStart - 1, effectiveRange: &effective) + if effective.length > 0 { scanStart = effective.location } + } + var scanEnd = min(charRange.upperBound, storage.length) + if scanEnd < storage.length { + var effective = NSRange(location: 0, length: 0) + _ = storage.attribute(.picoBlockquoteLevel, at: scanEnd, effectiveRange: &effective) + if effective.length > 0 { scanEnd = max(scanEnd, effective.upperBound) } + } + guard scanEnd > scanStart else { return } + + var segments: [BarSegment] = [] + let scanRange = NSRange(location: scanStart, length: scanEnd - scanStart) + storage.enumerateAttribute(.picoBlockquoteLevel, in: scanRange, options: []) { value, range, _ in + guard let level = value as? Int, level > 0, range.length > 0 else { return } + let glyphs = glyphRange(forCharacterRange: range, actualCharacterRange: nil) + guard glyphs.length > 0, + let container = textContainer(forGlyphAt: glyphs.location, effectiveRange: nil) else { return } + var rect = boundingRect(forGlyphRange: glyphs, in: container) + rect.origin.x = origin.x + rect.origin.y += origin.y + let color = storage.attribute(.picoBlockquoteBarColor, + at: range.location, + effectiveRange: nil) as? MarkdownColor + segments.append(BarSegment(level: level, color: color, range: range, rect: rect)) + } + + // Draw each segment's bars, extending a bar down to the next segment + // when the neighbor is adjacent in the text and shares that level, so + // the bar runs continuously across paragraph gaps and nested quotes + // (GitHub-style) instead of breaking at every block boundary. + for (index, segment) in segments.enumerated() { + let next = index + 1 < segments.count ? segments[index + 1] : nil + let joinsNext = next.map { segment.range.upperBound == $0.range.location } ?? false + for barIndex in 0.. barIndex { + bottom = max(bottom, next.rect.minY) + } + BlockquoteBarDrawer.fillBar(atIndex: barIndex, + x: segment.rect.minX, + top: segment.rect.minY, + bottom: bottom, + color: segment.color ?? BlockquoteBarDrawer.fallbackColor) + } + } + } +} + +#if canImport(UIKit) +/// TextKit 2 hook (iOS 16+): a layout fragment that draws blockquote bars +/// across its own height before rendering the paragraph text. +@available(iOS 16.0, *) +final class BlockquoteBarTextLayoutFragment: NSTextLayoutFragment { + private var quoteLevel: Int { + guard let paragraph = textElement as? NSTextParagraph, + paragraph.attributedString.length > 0, + let level = paragraph.attributedString.attribute(.picoBlockquoteLevel, + at: 0, + effectiveRange: nil) as? Int + else { return 0 } + return level + } + + private var barColor: MarkdownColor { + guard let paragraph = textElement as? NSTextParagraph, + paragraph.attributedString.length > 0, + let color = paragraph.attributedString.attribute(.picoBlockquoteBarColor, + at: 0, + effectiveRange: nil) as? MarkdownColor + else { return BlockquoteBarDrawer.fallbackColor } + return color + } + + override var renderingSurfaceBounds: CGRect { + let level = quoteLevel + guard level > 0 else { return super.renderingSurfaceBounds } + // Extend the drawing area to cover the leading gutter where the bars + // live (text is indented past it, so the default surface may exclude it). + let gutter = CGRect(x: 0, + y: 0, + width: BlockquoteBarMetrics.textIndent(level: level), + height: layoutFragmentFrame.height) + return super.renderingSurfaceBounds.union(gutter) + } + + override func draw(at point: CGPoint, in context: CGContext) { + let level = quoteLevel + if level > 0 { + context.saveGState() + UIGraphicsPushContext(context) + let barRect = CGRect(x: point.x, + y: point.y, + width: BlockquoteBarMetrics.textIndent(level: level), + height: layoutFragmentFrame.height) + BlockquoteBarDrawer.drawBars(level: level, color: barColor, in: barRect) + UIGraphicsPopContext() + context.restoreGState() + } + super.draw(at: point, in: context) + } +} +#endif diff --git a/Sources/PicoMarkdownView/Views/TextKitStreamingController.swift b/Sources/PicoMarkdownView/Views/TextKitStreamingController.swift index c0442da..8d604c6 100644 --- a/Sources/PicoMarkdownView/Views/TextKitStreamingController.swift +++ b/Sources/PicoMarkdownView/Views/TextKitStreamingController.swift @@ -342,7 +342,7 @@ final class TextKitStreamingBackend { if index < records.count && records[index].id == block.id && records[index].content == block.content { return (block: block, attributed: records[index].nsAttributed) } else { - return (block: block, attributed: NSAttributedString(block.content)) + return (block: block, attributed: NSAttributedString.picoConverted(from: block.content)) } } @@ -444,6 +444,13 @@ final class TextKitStreamingBackend { let index = max(0, min(position, blocks.count)) guard index < blocks.count, blocks[index].id == id else { continue } updatedSelection = insertRecord(blocks[index], at: index, selection: updatedSelection) + // A new child changes what its parent renders (container-only + // quote parents render nothing once a child exists) and the + // diff carries no change entry for the parent — sync its + // record too. No-op when the parent's content is unchanged. + if let parentID = blocks[index].snapshot.parentID { + updatedSelection = updateRecord(id: parentID, blocks: blocks, selection: updatedSelection) + } case .runsAppended(let id, _), .codeAppended(let id, _), .tableHeaderConfirmed(let id), @@ -459,7 +466,7 @@ final class TextKitStreamingBackend { private func insertRecord(_ block: RenderedBlock, at index: Int, selection: NSRange) -> NSRange { - let attributed = NSAttributedString(block.content) + let attributed = NSAttributedString.picoConverted(from: block.content) let record = BlockRecord(id: block.id, content: block.content, nsAttributed: attributed, @@ -489,7 +496,7 @@ final class TextKitStreamingBackend { let record = records[index] guard record.content != block.content else { return selection } - let newAttributed = NSAttributedString(block.content) + let newAttributed = NSAttributedString.picoConverted(from: block.content) let range = rangeForRecord(at: index) storage.replaceCharacters(in: range, with: newAttributed) let updatedSelection = adjust(selection: selection, editedRange: range, replacementLength: newAttributed.length) @@ -742,7 +749,7 @@ private final class StreamingTextKit1View: UITextView, UITextViewDelegate { private var lastReportedContentSize: CGSize = CGSize(width: -1, height: -1) init(backend: TextKitStreamingBackend) { - let layoutManager = NSLayoutManager() + let layoutManager = BlockquoteBarLayoutManager() let textContainer = NSTextContainer(size: .zero) layoutManager.addTextContainer(textContainer) backend.connect(to: layoutManager) @@ -807,7 +814,20 @@ private final class StreamingTextKit1View: UITextView, UITextViewDelegate { @available(iOS 16.0, *) @MainActor -private final class StreamingTextKit2View: UITextView, UITextViewDelegate { +private final class StreamingTextKit2View: UITextView, UITextViewDelegate, NSTextLayoutManagerDelegate { + // Nonisolated: NSTextLayoutManagerDelegate is not main-actor bound (TextKit 2 + // may lay out off the main thread), and this only inspects the element. + nonisolated func textLayoutManager(_ textLayoutManager: NSTextLayoutManager, + textLayoutFragmentFor location: NSTextLocation, + in textElement: NSTextElement) -> NSTextLayoutFragment { + if let paragraph = textElement as? NSTextParagraph, + paragraph.attributedString.length > 0, + paragraph.attributedString.attribute(.picoBlockquoteLevel, at: 0, effectiveRange: nil) != nil { + return BlockquoteBarTextLayoutFragment(textElement: textElement, range: textElement.elementRange) + } + return NSTextLayoutFragment(textElement: textElement, range: textElement.elementRange) + } + var onMermaidContentWidthChanged: ((CGFloat?) -> Void)? var onContentSizeChanged: ((CGSize) -> Void)? var linkActionHandler: ((URL, String) -> Void)? @@ -821,6 +841,9 @@ private final class StreamingTextKit2View: UITextView, UITextViewDelegate { if let layoutManager = textLayoutManager, let contentStorage = layoutManager.textContentManager as? NSTextContentStorage { backend.connect(to: contentStorage, layoutManager: layoutManager) + // Blockquote bars are drawn by custom layout fragments (see + // BlockquoteBarDecoration.swift). + layoutManager.delegate = self } delegate = self } @@ -943,7 +966,7 @@ private final class StreamingTextKit1View: NSTextView { } init(backend: TextKitStreamingBackend) { - let layoutManager = NSLayoutManager() + let layoutManager = BlockquoteBarLayoutManager() let textContainer = NSTextContainer(size: NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)) layoutManager.addTextContainer(textContainer) @@ -1105,7 +1128,7 @@ private final class StreamingTextKit2View: NSTextView { // adding a layout manager to the backend's own NSTextStorage. TextKit 2's // NSTextContentStorage observation chain does not properly relay // programmatic NSTextStorage edits on macOS, resulting in blank views. - let layoutManager = NSLayoutManager() + let layoutManager = BlockquoteBarLayoutManager() let textContainer = NSTextContainer(size: NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)) layoutManager.addTextContainer(textContainer) diff --git a/Tests/PicoMarkdownViewTests/MarkdownTokenizerGoldenTests.swift b/Tests/PicoMarkdownViewTests/MarkdownTokenizerGoldenTests.swift index 27f1bdd..ccdcde5 100644 --- a/Tests/PicoMarkdownViewTests/MarkdownTokenizerGoldenTests.swift +++ b/Tests/PicoMarkdownViewTests/MarkdownTokenizerGoldenTests.swift @@ -1566,6 +1566,185 @@ struct MarkdownTokenizerGoldenTests { ), state: &state) } + @Test("Nested blockquotes open child blocks per marker depth") + func nestedBlockquoteDepths() async { + let tokenizer = MarkdownTokenizer() + var state = EventNormalizationState() + + let first = await tokenizer.feed("> Level one\n") + assertChunk(first, matches: .init( + events: [ + .blockStart(.blockquote), + .blockAppendInline(.blockquote, runs: [plain("Level one"), plain("\n")]) + ], + openBlocks: [.blockquote] + ), state: &state) + + // `>>` (no inner space) deepens by one level. + let second = await tokenizer.feed(">> Level two\n") + assertChunk(second, matches: .init( + events: [ + .blockStart(.blockquote), + .blockAppendInline(.blockquote, runs: [plain("Level two"), plain("\n")]) + ], + openBlocks: [.blockquote, .blockquote] + ), state: &state) + + // `> > >` (spaced markers) deepens to level three; the trailing blank + // line closes the whole quote stack, deepest first. + let third = await tokenizer.feed("> > > Level three\n\n") + assertChunk(third, matches: .init( + events: [ + .blockStart(.blockquote), + .blockAppendInline(.blockquote, runs: [plain("Level three"), plain("\n")]), + .blockEnd(.blockquote), + .blockEnd(.blockquote), + .blockEnd(.blockquote) + ], + openBlocks: [] + ), state: &state) + } + + @Test("Shallower blockquote markers lazily continue the deepest open quote") + func nestedBlockquoteLazyContinuation() async { + let tokenizer = MarkdownTokenizer() + var state = EventNormalizationState() + + let first = await tokenizer.feed("> > deep\n") + assertChunk(first, matches: .init( + events: [ + .blockStart(.blockquote), + .blockStart(.blockquote), + .blockAppendInline(.blockquote, runs: [plain("deep"), plain("\n")]) + ], + openBlocks: [.blockquote, .blockquote] + ), state: &state) + + // CommonMark lazy continuation: a line with fewer markers continues + // the open paragraph of the innermost quote — it does not close it. + let second = await tokenizer.feed("> still deep\n\n") + assertChunk(second, matches: .init( + events: [ + .blockAppendInline(.blockquote, runs: [plain("still deep"), plain("\n")]), + .blockEnd(.blockquote), + .blockEnd(.blockquote) + ], + openBlocks: [] + ), state: &state) + } + + @Test("Padded nested markers still deepen (CommonMark ≤3-space indent)") + func paddedNestedBlockquoteDeepens() async { + let tokenizer = MarkdownTokenizer() + var state = EventNormalizationState() + + let result = await tokenizer.feed("> > padded nested\n\n") + assertChunk(result, matches: .init( + events: [ + .blockStart(.blockquote), + .blockStart(.blockquote), + .blockAppendInline(.blockquote, runs: [plain("padded nested"), plain("\n")]), + .blockEnd(.blockquote), + .blockEnd(.blockquote) + ], + openBlocks: [] + ), state: &state) + } + + @Test("Whitespace-padded separator lines emit no content runs") + func whitespacePaddedSeparatorEmitsNoRuns() async { + let tokenizer = MarkdownTokenizer() + var state = EventNormalizationState() + + // `> ` (marker + trailing spaces) is a paragraph separator like `>`; + // its leftover whitespace must not leak into the event stream (nor + // trigger a hard-break newline from the trailing double space). + let result = await tokenizer.feed("> First para\n> \n> Second para\n\n") + assertChunk(result, matches: .init( + events: [ + .blockStart(.blockquote), + .blockAppendInline(.blockquote, runs: [plain("First para"), plain("\n")]), + .blockEnd(.blockquote), + .blockStart(.blockquote), + .blockAppendInline(.blockquote, runs: [plain("Second para"), plain("\n")]), + .blockEnd(.blockquote) + ], + openBlocks: [] + ), state: &state) + } + + @Test("Marker-only quote line closes the quote for unquoted continuations") + func quoteMarkerOnlyLineClosesQuote() async { + let tokenizer = MarkdownTokenizer() + var state = EventNormalizationState() + + // The `>` separator ends the quote immediately, so an unquoted line + // after it starts a normal paragraph instead of lazily continuing + // the old quote. + let result = await tokenizer.feed("> quoted\n>\n") + assertChunk(result, matches: .init( + events: [ + .blockStart(.blockquote), + .blockAppendInline(.blockquote, runs: [plain("quoted"), plain("\n")]), + .blockEnd(.blockquote) + ], + openBlocks: [] + ), state: &state) + } + + @Test("Marker-only quote line splits the quote into separate blocks") + func quoteMarkerOnlyLineSplitsParagraphs() async { + let tokenizer = MarkdownTokenizer() + var state = EventNormalizationState() + + let result = await tokenizer.feed("> First para\n>\n> Second para\n\n") + assertChunk(result, matches: .init( + events: [ + .blockStart(.blockquote), + .blockAppendInline(.blockquote, runs: [plain("First para"), plain("\n")]), + .blockEnd(.blockquote), + .blockStart(.blockquote), + .blockAppendInline(.blockquote, runs: [plain("Second para"), plain("\n")]), + .blockEnd(.blockquote) + ], + openBlocks: [] + ), state: &state) + } + + @Test("Quote returns to the outer level after a marker-only separator") + func nestedQuoteReturnsToOuterLevelAfterBreak() async { + let tokenizer = MarkdownTokenizer() + var state = EventNormalizationState() + + // The classic Markdown.pl / GitHub sample: + // > This is the first level of quoting. + // > + // > > This is nested blockquote. + // > + // > Back to the first level. + let result = await tokenizer.feed("> First level\n>\n> > Nested\n>\n> Back to first\n\n") + assertChunk(result, matches: .init( + events: [ + .blockStart(.blockquote), + .blockAppendInline(.blockquote, runs: [plain("First level"), plain("\n")]), + .blockEnd(.blockquote), + // The nested line reopens the stack at depth 2 (an empty + // level-1 parent plus the level-2 quote holding the text). + .blockStart(.blockquote), + .blockStart(.blockquote), + .blockAppendInline(.blockquote, runs: [plain("Nested"), plain("\n")]), + // "Back to first." exits the nested quote into a fresh + // level-1 block instead of lazily continuing depth 2. + .blockEnd(.blockquote), + .blockEnd(.blockquote), + .blockStart(.blockquote), + .blockAppendInline(.blockquote, runs: [plain("Back to first"), plain("\n")]), + .blockEnd(.blockquote) + ], + openBlocks: [] + ), state: &state) + } + @Test("List item with continuation line") func listItemWithContinuation() async { let tokenizer = MarkdownTokenizer() diff --git a/Tests/PicoMarkdownViewTests/Renderer/MarkdownRendererTests.swift b/Tests/PicoMarkdownViewTests/Renderer/MarkdownRendererTests.swift index 9b548f6..38b481e 100644 --- a/Tests/PicoMarkdownViewTests/Renderer/MarkdownRendererTests.swift +++ b/Tests/PicoMarkdownViewTests/Renderer/MarkdownRendererTests.swift @@ -715,6 +715,227 @@ struct MarkdownRendererTests { #expect(sub.first == sub.last) } + @Test("List item ending in a styled span does not gain a blank line") + func listItemEndingInCodeSpanStaysTight() async { + let tokenizer = MarkdownTokenizer() + let assembler = MarkdownAssembler() + let renderer = MarkdownRenderer { id in + await assembler.block(id) + } + + // The first item ends in an inline-code span, so its trailing "\n" + // run cannot coalesce into the plain text. It used to survive into + // the render and, combined with the item's own "\n" terminator, + // produce an empty paragraph — a full blank line between bullets. + let markdown = "* Ends with `code`\n* Second item\n\n" + let chunk = await tokenizer.feed(markdown) + let diff = await assembler.apply(chunk) + _ = await renderer.apply(diff) + let finish = await tokenizer.finish() + let finishDiff = await assembler.apply(finish) + _ = await renderer.apply(finishDiff) + + let blocks = await renderer.renderedBlocks() + let listItems = blocks.filter { $0.listItem != nil } + #expect(listItems.count == 2) + + for item in listItems { + let text = String(NSAttributedString(item.content).string) + #expect(!text.contains("\n\n"), "list item rendered an empty paragraph: \(text.debugDescription)") + #expect(text.hasSuffix("\n") && !text.hasSuffix("\n\n")) + } + } + + @Test("Nested blockquotes carry the drawn-bar level attribute, not glyphs") + func nestedBlockquotesCarryLevelAttribute() async { + let tokenizer = MarkdownTokenizer() + let assembler = MarkdownAssembler() + let renderer = MarkdownRenderer { id in + await assembler.block(id) + } + + let markdown = "> Level one\n>> Level two\n> > > Level three\n\n" + let chunk = await tokenizer.feed(markdown) + let diff = await assembler.apply(chunk) + _ = await renderer.apply(diff) + let finish = await tokenizer.finish() + let finishDiff = await assembler.apply(finish) + _ = await renderer.apply(finishDiff) + + let blocks = await renderer.renderedBlocks() + let quotes = blocks.filter { $0.blockquote != nil } + #expect(quotes.count == 3, "expected three nested blockquote blocks, got \(quotes.count)") + + for block in quotes { + let ns = NSAttributedString.picoConverted(from: block.content) + // Bars are drawn by the view layer, not baked into the text — + // selection/copy must not contain bar characters. The custom + // attribute must survive the AttributedString round-trip, cover + // the whole block (incl. the trailing newline, so adjacent quote + // bars merge), and match depth + 1. + #expect(!ns.string.contains("│"), "bar glyphs leaked into text: \(ns.string.debugDescription)") + guard ns.length > 0 else { + Issue.record("empty quote block") + continue + } + var effective = NSRange(location: 0, length: 0) + let level = ns.attribute(.picoBlockquoteLevel, at: 0, effectiveRange: &effective) as? Int + #expect(level == block.snapshot.depth + 1) + #expect(effective == NSRange(location: 0, length: ns.length), + "level attribute must span the whole block") + let style = ns.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle + #expect(style?.headIndent == BlockquoteBarMetrics.textIndent(level: block.snapshot.depth + 1)) + } + } + + @Test("Marker-only separators split quotes and return to the outer level") + func quoteSeparatorReturnsToOuterLevel() async { + let tokenizer = MarkdownTokenizer() + let assembler = MarkdownAssembler() + let renderer = MarkdownRenderer { id in + await assembler.block(id) + } + + // The classic Markdown.pl nested-quote sample. GitHub renders: + // level 1, a nested level 2, then back to level 1. + let markdown = "> First level.\n>\n> > Nested.\n>\n> Back to first.\n\n" + let chunk = await tokenizer.feed(markdown) + let diff = await assembler.apply(chunk) + _ = await renderer.apply(diff) + let finish = await tokenizer.finish() + let finishDiff = await assembler.apply(finish) + _ = await renderer.apply(finishDiff) + + let blocks = await renderer.renderedBlocks() + let quotes = blocks.filter { $0.blockquote != nil } + let shapes = quotes.map { block -> (depth: Int, level: Int?, text: String) in + let ns = NSAttributedString.picoConverted(from: block.content) + let level = ns.length > 0 + ? ns.attribute(.picoBlockquoteLevel, at: 0, effectiveRange: nil) as? Int + : nil + return (block.snapshot.depth, level, ns.string) + } + + // Document order: first level, the nested quote (its container-only + // level-1 parent renders nothing), then a fresh level-1 block. + #expect(shapes.count == 3, "expected 3 rendered quote blocks, got \(shapes)") + guard shapes.count == 3 else { return } + #expect(shapes[0].depth == 0 && shapes[0].level == 1 && shapes[0].text.hasPrefix("First level.")) + #expect(shapes[1].depth == 1 && shapes[1].level == 2 && shapes[1].text.hasPrefix("Nested.")) + #expect(shapes[2].depth == 0 && shapes[2].level == 1 && shapes[2].text.hasPrefix("Back to first.")) + } + + @Test("Image-only quote parents are not suppressed as container-only") + func imageOnlyQuoteParentKeepsContent() async { + let tokenizer = MarkdownTokenizer() + let assembler = MarkdownAssembler() + let renderer = MarkdownRenderer { id in + await assembler.block(id) + } + + // The parent quote's only content is an image with an empty alt text; + // it must still render (and surface its image), not be treated as a + // container-only parent of the nested quote. + let markdown = "> ![](https://example.com/a.png)\n>> nested\n\n" + let chunk = await tokenizer.feed(markdown) + _ = await renderer.apply(await assembler.apply(chunk)) + let finish = await tokenizer.finish() + _ = await renderer.apply(await assembler.apply(finish)) + + let blocks = await renderer.renderedBlocks() + let quotes = blocks.filter { $0.blockquote != nil } + #expect(quotes.count == 2, "expected image parent + nested quote, got \(quotes.count)") + let parent = quotes.first { $0.snapshot.depth == 0 } + #expect(parent?.images.isEmpty == false, "parent quote must surface its image") + } + + @Test("Empty quote parents refresh when their child streams in later") + func emptyQuoteParentRefreshesOnChildInsertion() async { + let tokenizer = MarkdownTokenizer() + let assembler = MarkdownAssembler() + let renderer = MarkdownRenderer { id in + await assembler.block(id) + } + + // Split the nested marker across chunks: the outer quote opens (and + // renders as a blank quoted line) before its child exists. Inserting + // the child must refresh the parent so the blank line disappears + // while the quote is still streaming. + let first = await tokenizer.feed("> ") + _ = await renderer.apply(await assembler.apply(first)) + let second = await tokenizer.feed("> nested\n") + _ = await renderer.apply(await assembler.apply(second)) + + let blocks = await renderer.renderedBlocks() + let parent = blocks.first { $0.kind == .blockquote && $0.snapshot.depth == 0 } + #expect(parent != nil) + #expect(NSAttributedString(parent?.content ?? AttributedString()).length == 0, + "container-only parent must render empty mid-stream") + } + + @Test("Quote ending in a styled span does not gain a blank quoted line") + func quoteEndingInCodeSpanStaysTight() async { + let tokenizer = MarkdownTokenizer() + let assembler = MarkdownAssembler() + let renderer = MarkdownRenderer { id in + await assembler.block(id) + } + + let markdown = "> ends with `code`\n\n" + let chunk = await tokenizer.feed(markdown) + _ = await renderer.apply(await assembler.apply(chunk)) + let finish = await tokenizer.finish() + _ = await renderer.apply(await assembler.apply(finish)) + + let blocks = await renderer.renderedBlocks() + let quote = blocks.first { $0.blockquote != nil } + let text = NSAttributedString.picoConverted(from: quote?.content ?? AttributedString()).string + #expect(!text.contains("\n\n"), "quote rendered a blank quoted line: \(text.debugDescription)") + #expect(text.hasSuffix("\n") && !text.hasSuffix("\n\n")) + } + + @Test("Trailing space inside a quoted code span is preserved") + func quotedCodeSpanTrailingSpaceSurvives() async { + let tokenizer = MarkdownTokenizer() + let assembler = MarkdownAssembler() + let renderer = MarkdownRenderer { id in + await assembler.block(id) + } + + // The code span's final character is a meaningful space; trimming + // must only remove the synthetic trailing newline, not content. + let markdown = "> run `git push `\n\n" + let chunk = await tokenizer.feed(markdown) + _ = await renderer.apply(await assembler.apply(chunk)) + let finish = await tokenizer.finish() + _ = await renderer.apply(await assembler.apply(finish)) + + let blocks = await renderer.renderedBlocks() + let quote = blocks.first { $0.blockquote != nil } + let text = NSAttributedString.picoConverted(from: quote?.content ?? AttributedString()).string + #expect(text.contains("git push "), "code span trailing space was trimmed: \(text.debugDescription)") + } + + @Test("Blockquote bar attributes survive the AttributedString round-trip") + func blockquoteAttributesSurviveConversion() { + let source = NSMutableAttributedString(string: "quoted text\n") + source.addAttributes([ + .picoBlockquoteLevel: 2, + .picoBlockquoteBarColor: MarkdownColor.red + ], range: NSRange(location: 0, length: source.length)) + + // The pipeline's interchange type is AttributedString; the view layer + // reads these keys back out of NSTextStorage. The PLAIN conversion + // initializers drop custom keys, which is why every conversion at the + // pipeline seams must go through the pico-scoped helpers. If this + // fails, drawn blockquote bars are silently lost. + let roundTripped = NSAttributedString.picoConverted(from: .picoConverted(from: source)) + let level = roundTripped.attribute(.picoBlockquoteLevel, at: 0, effectiveRange: nil) as? Int + let color = roundTripped.attribute(.picoBlockquoteBarColor, at: 0, effectiveRange: nil) as? MarkdownColor + #expect(level == 2) + #expect(color != nil) + } + @Test("Removing trailing blocks updates cache without crashing") func removingTrailingBlocksDoesNotCrash() async { let store = TestSnapshotStore() diff --git a/Tests/PicoMarkdownViewTests/Views/TextKitStreamingBackendTests.swift b/Tests/PicoMarkdownViewTests/Views/TextKitStreamingBackendTests.swift index 005b66f..d962b89 100644 --- a/Tests/PicoMarkdownViewTests/Views/TextKitStreamingBackendTests.swift +++ b/Tests/PicoMarkdownViewTests/Views/TextKitStreamingBackendTests.swift @@ -51,19 +51,49 @@ final class TextKitStreamingBackendTests: XCTestCase { XCTAssertTrue(cachedFirst === cachedSecond) } - private func makeBlock(id: BlockID, text: String) -> RenderedBlock { + func testChildInsertionSyncsRefreshedParentRecord() { + let backend = TextKitStreamingBackend() + + // Streamed split-marker scenario: the parent quote is inserted as a + // blank quoted line before its child exists… + let blankParent = makeBlock(id: 1, text: "\n", kind: .blockquote) + let insertParent = AssemblerDiff(documentVersion: 1, + changes: [.blockStarted(id: 1, kind: .blockquote, position: 0)]) + _ = backend.apply(blocks: [blankParent], diffs: [insertParent], selection: NSRange(location: 0, length: 0)) + XCTAssertEqual(backend.snapshotAttributedString().string, "\n") + + // …then the child arrives. The renderer re-renders the parent as + // empty (container-only), and the diff only mentions the child — the + // backend must sync the parent record too. + let emptyParent = makeBlock(id: 1, text: "", kind: .blockquote) + let child = makeBlock(id: 2, text: "nested\n", kind: .blockquote, parentID: 1, depth: 1) + let insertChild = AssemblerDiff(documentVersion: 2, + changes: [.blockStarted(id: 2, kind: .blockquote, position: 1)]) + _ = backend.apply(blocks: [emptyParent, child], + diffs: [insertChild], + selection: NSRange(location: 0, length: 0)) + + XCTAssertEqual(backend.snapshotAttributedString().string, "nested\n", + "stale blank parent line must be removed when its child streams in") + } + + private func makeBlock(id: BlockID, + text: String, + kind: BlockKind = .paragraph, + parentID: BlockID? = nil, + depth: Int = 0) -> RenderedBlock { let snapshot = BlockSnapshot(id: id, - kind: .paragraph, + kind: kind, inlineRuns: nil, codeText: nil, mathText: nil, table: nil, isClosed: true, - parentID: nil, - depth: 0, + parentID: parentID, + depth: depth, childIDs: []) return RenderedBlock(id: id, - kind: .paragraph, + kind: kind, content: AttributedString(text), snapshot: snapshot, table: nil,