diff --git a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift index f33c2af..94e8d86 100644 --- a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift +++ b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift @@ -27,11 +27,18 @@ struct MarkdownLists { textView.didChangeText() } + // Markers: `-`/`*`/`+` (raw Markdown) + legacy `•` (rendered, never typed). static let listRegex = try! NSRegularExpression( - pattern: #"^\s*((?:(\d+)\.|[-•])(?:\s+\[[ xX]\])?\s+)"# + pattern: #"^\s*((?:(\d+)\.|[-•*+])(?:\s+\[[ xX]\])?\s+)"# + ) + /// CommonMark blockquote line: ≤3 spaces of leading indent, then a run + /// of `>` markers, then an optional single space before content. The + /// captures are: (1) leading whitespace, (2) the `>`/`>>`… marker run. + /// Blockquote line: ≤3 indent + one or more `>` markers (also `> >` with spaces); group 2 captures the marker run. + static let blockquoteRegex = try! NSRegularExpression( + pattern: #"^( {0,3})(>+(?:[ \t]+>+)*)"# ) static let dashNoSpaceRegex = try! NSRegularExpression(pattern: #"^\s*-(?!\s)"#) - static let numberRegex = try! NSRegularExpression(pattern: #"^\s*(\d+)\.$"#) static let leadingWhitespaceRegex = try! NSRegularExpression(pattern: #"^\s*"#) static func indentLevel(from leadingWhitespace: String) -> Int { @@ -40,6 +47,27 @@ struct MarkdownLists { return tabCount + (spaceCount / 2) } + /// Remove the leading prefix on the current line (list marker, quote + /// marker, …) and place the caret at the line start. Used by Enter + /// handling when the marker has no content, so the user exits the block + /// without having to backspace through the prefix. + private static func removeLinePrefixAndExit( + textView: NSTextView, + currentLineRange: NSRange, + prefixLength: Int + ) -> Bool { + let lineEnd = currentLineRange.location + currentLineRange.length + let hasNewline = currentLineRange.length > 0 + && (textView.string as NSString) + .substring(with: NSRange(location: lineEnd - 1, length: 1)) == "\n" + let maxBodyLen = hasNewline ? currentLineRange.length - 1 : currentLineRange.length + let removalLength = min(prefixLength, maxBodyLen) + let removalRange = NSRange(location: currentLineRange.location, length: removalLength) + performEdit(textView, replace: removalRange, with: "") + textView.setSelectedRange(NSRange(location: currentLineRange.location, length: 0)) + return false + } + // MARK: - Paragraph Attributes for List Styling static func paragraphAttributes( @@ -57,7 +85,6 @@ struct MarkdownLists { let indentPerLevel = configuration.lists.indentPerLevel let extraLineHeight = configuration.lists.extraLineHeight - let spaceWidth = (" " as NSString).size(withAttributes: [.font: baseFont]).width func applyListMatches(_ matches: [NSTextCheckingResult]) { for match in matches { @@ -70,9 +97,8 @@ struct MarkdownLists { let wsRange = match.range(at: 1) let markerRange = match.range(at: 2) let ws = nsText.substring(with: wsRange) - let tabCount = ws.filter { $0 == "\t" }.count - let spaceCount = ws.filter { $0 == " " }.count - let depthIndent = CGFloat(tabCount) * indentPerLevel + CGFloat(spaceCount) * spaceWidth + // CommonMark nesting: 1 tab OR 2 spaces = one level deep. + let depthIndent = CGFloat(MarkdownLists.indentLevel(from: ws)) * indentPerLevel let markerString = nsText.substring(with: markerRange) as NSString let markerWidth = markerString.size(withAttributes: [.font: baseFont]).width @@ -84,8 +110,10 @@ struct MarkdownLists { ps.tabStops = [] ps.defaultTabInterval = indentPerLevel - ps.firstLineHeadIndent = 0 - ps.headIndent = depthIndent + markerWidth + extraSpacing + // Base lead indent: top-level item lines up with where legacy `\t• ` placed it. + let leadIndent = indentPerLevel + ps.firstLineHeadIndent = leadIndent + ps.headIndent = leadIndent + depthIndent + markerWidth + extraSpacing attributesList.append((match.range(at: 0), [.paragraphStyle: ps])) } @@ -98,9 +126,10 @@ struct MarkdownLists { } // Bullet lists - let bulletListPattern = #"^([ \t]*)([-•](?:[ \t]+\[[ xX]\])?[ \t]+)(.*)$"# + let bulletListPattern = #"^([ \t]*)([-•*+](?:[ \t]+\[[ xX]\])?[ \t]+)(.*)$"# if let bulletListRegex = try? NSRegularExpression(pattern: bulletListPattern, options: [.anchorsMatchLines]) { - applyListMatches(bulletListRegex.matches(in: text, options: [], range: fullRange)) + let bulletMatches = bulletListRegex.matches(in: text, options: [], range: fullRange) + applyListMatches(bulletMatches) } return attributesList } @@ -132,6 +161,7 @@ struct MarkdownLists { let isInCodeBlock = textView.string.contains("`") ? MarkdownDetection.isInsideCodeBlock(location: affectedCharRange.location, in: textView.string) : false + if replacementString == ">" && affectedCharRange.length == 0 && !isInCodeBlock { let insertionLocation = affectedCharRange.location guard insertionLocation > 0 else { return true } @@ -213,57 +243,21 @@ struct MarkdownLists { return true } - // SPACE: convert "-" or "1." to proper markers (skip in code blocks) - if replacementString == " " && !isInCodeBlock { - guard listsEnabled else { return true } - let insertionLocation = affectedCharRange.location - if insertionLocation > 0 { - let nsText = textView.string as NSString - let prevCharRange = NSRange(location: insertionLocation - 1, length: 1) - let prevChar = nsText.substring(with: prevCharRange) - let currentLineRange = nsText.lineRange(for: NSRange(location: insertionLocation - 1, length: 0)) - let currentLine = nsText.substring(with: currentLineRange) - if let match = MarkdownLists.numberRegex.firstMatch(in: currentLine, range: NSRange(location: 0, length: currentLine.utf16.count)) { - let numberRange = match.range(at: 1) - let numberString = (currentLine as NSString).substring(with: numberRange) - let markerRange = NSRange(location: currentLineRange.location + match.range.location, length: match.range.length) - MarkdownLists.performEdit(textView, replace: markerRange, with: "\t\(numberString). ") - return false - } - if prevChar == "-" { - let beforePrevIndex = insertionLocation - 2 - let isAtLineStart: Bool = (beforePrevIndex < 0) || nsText.substring(with: NSRange(location: beforePrevIndex, length: 1)) == "\n" - if isAtLineStart { - MarkdownLists.performEdit(textView, replace: prevCharRange, with: "\t• ") - return false - } - } - } - } - - // ENTER: HR expansion and list continuation/outdent + // ENTER: list continuation/outdent if replacementString == "\n" { let nsText = textView.string as NSString let safeLocENTER = min(affectedCharRange.location, nsText.length) let currentLineRange = nsText.lineRange(for: NSRange(location: safeLocENTER, length: 0)) let currentLine = nsText.substring(with: currentLineRange).trimmingCharacters(in: .whitespacesAndNewlines) - // Horizontal rule expansion - if currentLine.range(of: "^-{3,}$", options: .regularExpression) != nil { - let hrFont = (textView as? NativeTextView)?.baseFont - ?? textView.font - ?? NSFont.systemFont(ofSize: NSFont.systemFontSize) - let hyphenWidth = ("-" as NSString).size(withAttributes: [.font: hrFont]).width - let visibleWidth = textView.enclosingScrollView?.contentView.bounds.width - ?? textView.textContainer?.containerSize.width - ?? textView.bounds.width - let count = Int(visibleWidth / hyphenWidth) - let fullLine = String(repeating: "-", count: max(count, 3)) - let newString = fullLine + "\n" - MarkdownLists.performEdit(textView, replace: currentLineRange, with: newString) - textView.setSelectedRange(NSRange(location: currentLineRange.location + fullLine.count + 1, length: 0)) - return false - } + // Note: horizontal-rule rendering is handled entirely in the styler + // via the `.thematicBreak` attribute and a full-width band in + // `MarkdownTextLayoutFragment.drawThematicBreaks`. The source text + // stays as the literal `---` (or however many dashes the user + // typed) so the file round-trips through any other Markdown tool + // — no `Obsidian / Typora / Bear / iA Writer` expand source on + // Enter, and doing so here used to leave 80–120 dashes in the + // buffer that broke copy-paste, diffs, and inter-editor opening. if currentLine.range(of: "^```\\w*$", options: .regularExpression) != nil { let textBeforeLine = nsText.substring(to: currentLineRange.location) @@ -285,8 +279,35 @@ struct MarkdownLists { } } - // Skip list continuation in code blocks + // Skip list / blockquote continuation in code blocks. guard listsEnabled && !isInCodeBlock else { return true } + + // Blockquote continuation: `> foo` → `\n> `, `>>>` stays `>>>`, empty marker → exit. + let quoteLine = nsText.substring(with: currentLineRange) + if let quoteMatch = MarkdownLists.blockquoteRegex.firstMatch( + in: quoteLine, + range: NSRange(location: 0, length: quoteLine.utf16.count) + ) { + let ws = (quoteLine as NSString).substring(with: quoteMatch.range(at: 1)) + let markers = (quoteLine as NSString).substring(with: quoteMatch.range(at: 2)) + let prefixLength = quoteMatch.range.length + let contentStart = quoteMatch.range.location + prefixLength + let contentLength = quoteLine.utf16.count - contentStart + let contentText = (quoteLine as NSString) + .substring(with: NSRange(location: contentStart, length: contentLength)) + .trimmingCharacters(in: .whitespacesAndNewlines) + + if contentText.isEmpty { + return removeLinePrefixAndExit( + textView: textView, + currentLineRange: currentLineRange, + prefixLength: prefixLength + ) + } + MarkdownLists.performEdit(textView, replace: affectedCharRange, with: "\n" + ws + markers + " ") + return false + } + let listLine = nsText.substring(with: currentLineRange) if let match = MarkdownLists.listRegex.firstMatch(in: listLine, range: NSRange(location: 0, length: listLine.utf16.count)) { let contentStart = match.range.location + match.range.length @@ -294,15 +315,11 @@ struct MarkdownLists { let contentRangeLocal = NSRange(location: contentStart, length: contentLength) let contentText = (listLine as NSString).substring(with: contentRangeLocal).trimmingCharacters(in: .whitespacesAndNewlines) if contentText.isEmpty { - let removalLengthRaw = match.range.location + match.range.length - let lineEnd = currentLineRange.location + currentLineRange.length - let hasNewline = currentLineRange.length > 0 && (textView.string as NSString).substring(with: NSRange(location: lineEnd - 1, length: 1)) == "\n" - let maxBodyLen = hasNewline ? currentLineRange.length - 1 : currentLineRange.length - let removalLength = min(removalLengthRaw, maxBodyLen) - let removalRange = NSRange(location: currentLineRange.location, length: removalLength) - MarkdownLists.performEdit(textView, replace: removalRange, with: "") - textView.setSelectedRange(NSRange(location: currentLineRange.location, length: 0)) - return false + return removeLinePrefixAndExit( + textView: textView, + currentLineRange: currentLineRange, + prefixLength: match.range.location + match.range.length + ) } let leadingWhitespace: String if let wsMatch = MarkdownLists.leadingWhitespaceRegex.firstMatch(in: listLine, range: NSRange(location: 0, length: listLine.utf16.count)) { @@ -322,12 +339,15 @@ struct MarkdownLists { newListItem = "\n" + leadingWhitespace + "\(number + 1). " } } else { - let prefixIndent = leadingWhitespace.isEmpty ? " " : leadingWhitespace + // Continue the bullet with the user's own marker char + // (normalize a legacy `•` to `-`), preserving the line's + // exact leading whitespace so nesting carries over. Storage + // stays raw Markdown — the `•` glyph is drawn, not stored. + let bulletChar = (marker.first == "•") ? "-" : String(marker.prefix(1)) if hasCheckbox { - let bulletChar = marker.contains("•") ? "•" : "-" - newListItem = "\n" + prefixIndent + "\(bulletChar) [ ] " + newListItem = "\n" + leadingWhitespace + bulletChar + " [ ] " } else { - newListItem = "\n" + prefixIndent + marker + " " + newListItem = "\n" + leadingWhitespace + bulletChar + " " } } MarkdownLists.performEdit(textView, replace: affectedCharRange, with: newListItem) diff --git a/Sources/MarkdownEngine/Parser/MarkdownDetection.swift b/Sources/MarkdownEngine/Parser/MarkdownDetection.swift index df252bd..f69d466 100644 --- a/Sources/MarkdownEngine/Parser/MarkdownDetection.swift +++ b/Sources/MarkdownEngine/Parser/MarkdownDetection.swift @@ -41,6 +41,28 @@ enum MarkdownDetection { } } } + + // When a "container" token like a table is active (caret inside), + // every inline token fully contained within it should also be + // active. Otherwise inline-latex/inline-code/emphasis/etc. inside + // the table still try to render their decorated form (LaTeX + // images, hidden backticks, …) on top of the visible source the + // table editor mode is showing. + let activeContainers: [MarkdownToken] = indices.compactMap { idx in + let token = tokens[idx] + return token.kind == .table ? token : nil + } + if !activeContainers.isEmpty { + for (i, token) in tokens.enumerated() where !indices.contains(i) { + let tStart = token.range.location + let tEnd = NSMaxRange(token.range) + if activeContainers.contains(where: { + tStart >= $0.range.location && tEnd <= NSMaxRange($0.range) + }) { + indices.insert(i) + } + } + } return indices } diff --git a/Sources/MarkdownEngine/Parser/MarkdownToken.swift b/Sources/MarkdownEngine/Parser/MarkdownToken.swift index 9c4331f..f77c004 100644 --- a/Sources/MarkdownEngine/Parser/MarkdownToken.swift +++ b/Sources/MarkdownEngine/Parser/MarkdownToken.swift @@ -22,11 +22,22 @@ enum MarkdownTokenKind { case link case wikiLink case heading + /// One line of a blockquote. `markerRanges[0]` is the `>`/`>>`… run + /// (hidden when inactive); `contentRange` is the quoted text. The + /// nesting level is the count of `>` in the marker. + case blockquote case codeBlock case inlineCode case blockLatex case inlineLatex case imageEmbed + case imageLink + case strikethrough + case table + /// A CommonMark backslash escape (`\*`, `` \` ``, `\\`, …). The marker + /// is the backslash (hidden when inactive); the content is the single + /// escaped, now-literal punctuation character. + case backslashEscape } struct MarkdownToken { diff --git a/Sources/MarkdownEngine/Parser/MarkdownTokenizer.swift b/Sources/MarkdownEngine/Parser/MarkdownTokenizer.swift index 4f2ed6f..0a285ef 100644 --- a/Sources/MarkdownEngine/Parser/MarkdownTokenizer.swift +++ b/Sources/MarkdownEngine/Parser/MarkdownTokenizer.swift @@ -11,9 +11,31 @@ import Foundation // MARK: - Static Regexes private extension MarkdownTokenizer { + // `*`-emphasis is parsed by the stack parser (parseEmphasisTokens); + // `_`-emphasis and `~~`-strikethrough are not handled there, so we + // keep these regexes for them. + // + // Underscore-style emphasis: GFM disables intraword emphasis for `_`, + // so we require a non-word boundary on each side to avoid matching + // identifiers like `snake_case`. + static let boldItalicUnderscoreRegex = try! NSRegularExpression( + pattern: #"(?` markers + // (each optionally followed by one space), then the quoted content. + static let blockquoteRegex = try! NSRegularExpression( + pattern: #"^[ \t]{0,3}((?:>[ \t]?)+)(.*)$"#, + options: [.anchorsMatchLines] + ) static let taskListRegex = try! NSRegularExpression( pattern: #"^([ \t]*)([-•]|\d+\.)([ \t]+)(\[[ xX]\])(?=[ \t])"#, options: [.anchorsMatchLines] @@ -32,8 +60,11 @@ private extension MarkdownTokenizer { pattern: #"^```[ \t]*([A-Za-z0-9_+#.-]*?)[ \t]*\r?\n((?:(?!^```[^\r\n]*$)[\s\S])*?)^(```)[^\r\n]*$"#, options: [.anchorsMatchLines] ) + // CommonMark code span: an opening run of N backticks (not part of a + // longer run) closed by a run of exactly N backticks. The content may + // itself contain shorter/longer backtick runs (e.g. `` `tick` ``). static let inlineCodeRegex = try! NSRegularExpression( - pattern: "`([^`\\n]+)`", + pattern: #"(? 0 }) { continue } let textRange = match.range(at: 1) let urlRange = match.range(at: 2) let openBracket = NSRange(location: full.location, length: 1) @@ -140,6 +248,37 @@ enum MarkdownTokenizer { markerRanges: [openingMarker, closingMarker])) } + // Blockquote lines. After fenced code so a `>` inside a code block + // stays literal. One token per line; the styler stitches the bar. + for match in blockquoteRegex.matches(in: text, options: [], range: fullRange) { + let full = match.range(at: 0) + let marker = match.range(at: 1) + let content = match.range(at: 2) + let inCode = tokens.contains { + ($0.kind == .codeBlock || $0.kind == .blockLatex) + && NSIntersectionRange($0.range, full).length > 0 + } + if inCode { continue } + tokens.append(MarkdownToken(kind: .blockquote, + range: full, + contentRange: content, + markerRanges: [marker])) + } + + // GFM tables. Parsed after code blocks so we can skip table-shaped + // lines inside fenced code; sits before block-latex/inline-latex + // because we don't want `$$...$$` rules trying to claim ranges that + // belong to a table cell. + for match in tableRegex.matches(in: text, options: [], range: fullRange) { + let full = match.range(at: 0) + let inCode = tokens.contains { $0.kind == .codeBlock && NSIntersectionRange($0.range, full).length > 0 } + if inCode { continue } + tokens.append(MarkdownToken(kind: .table, + range: full, + contentRange: full, + markerRanges: [])) + } + // Block LaTeX $$...$$ (multiline) for match in blockLatexRegex.matches(in: text, options: [], range: fullRange) { let full = match.range(at: 0) @@ -155,16 +294,37 @@ enum MarkdownTokenizer { markerRanges: [openMarker, closeMarker])) } - // Inline code `code` + // Inline code `code` / `` `tick` `` (N-backtick runs) for match in inlineCodeRegex.matches(in: text, options: [], range: fullRange) { let full = match.range(at: 0) - let content = match.range(at: 1) - let openBacktick = NSRange(location: full.location, length: 1) - let closeBacktick = NSRange(location: full.location + full.length - 1, length: 1) + let delimLength = match.range(at: 1).length // run of N backticks + let rawContent = match.range(at: 2) + + // CommonMark: if the content both begins and ends with a space + // but isn't all spaces, strip exactly one space from each side. + let rawString = (text as NSString).substring(with: rawContent) + let stripsSpaces = rawString.count >= 2 + && rawString.first == " " + && rawString.last == " " + && rawString.contains(where: { $0 != " " }) + let lead = stripsSpaces ? 1 : 0 + let trail = stripsSpaces ? 1 : 0 + + let content = NSRange( + location: rawContent.location + lead, + length: rawContent.length - lead - trail + ) + // The markers swallow the delimiter runs AND any stripped space, + // so they collapse together when the syntax is hidden. + let openMarker = NSRange(location: full.location, length: delimLength + lead) + let closeMarker = NSRange( + location: full.location + full.length - delimLength - trail, + length: delimLength + trail + ) tokens.append(MarkdownToken(kind: .inlineCode, range: full, contentRange: content, - markerRanges: [openBacktick, closeBacktick])) + markerRanges: [openMarker, closeMarker])) } // Inline LaTeX $formula$ @@ -186,6 +346,66 @@ enum MarkdownTokenizer { markerRanges: [openDollar, closeDollar])) } + // MARK: Backslash escapes (CommonMark §2.4) + // + // A backslash before any ASCII punctuation character makes that + // character literal — it loses its Markdown meaning. We scan left + // to right so that `\\` consumes itself (the even/odd-backslash + // rule): the char after an escaping backslash can never itself + // start a new escape. Escapes do not apply inside fenced code or + // block LaTeX, where a backslash is already literal. + let asciiPunctuation: Set = { + let chars = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + return Set(chars.utf16) + }() + let escapeFreeRanges: [NSRange] = tokens + .filter { $0.kind == .codeBlock || $0.kind == .blockLatex } + .map { $0.range } + func isEscapeFree(_ loc: Int) -> Bool { + for r in escapeFreeRanges where loc >= r.location && loc < NSMaxRange(r) { + return true + } + return false + } + + var escapedCharOffsets: Set = [] + var escapeTokens: [MarkdownToken] = [] + var i = 0 + let textLength = nsText.length + while i < textLength - 1 { + if nsText.character(at: i) == 0x5C /* backslash */, !isEscapeFree(i) { + let next = nsText.character(at: i + 1) + if asciiPunctuation.contains(next) { + escapedCharOffsets.insert(i + 1) + escapeTokens.append(MarkdownToken( + kind: .backslashEscape, + range: NSRange(location: i, length: 2), + contentRange: NSRange(location: i + 1, length: 1), + markerRanges: [NSRange(location: i, length: 1)] + )) + i += 2 // the escaped char cannot start another escape + continue + } + } + i += 1 + } + + if !escapedCharOffsets.isEmpty { + // An inline span whose opening or closing delimiter sits on an + // escaped (now-literal) character is not a real span — drop it + // so `\*not italic\*` / `` \` not code \` `` stay literal. + let escapableKinds: Set = [ + .italic, .bold, .boldItalic, .strikethrough, + .inlineCode, .inlineLatex, .blockLatex, + .link, .wikiLink, .imageLink, .imageEmbed + ] + tokens.removeAll { token in + guard escapableKinds.contains(token.kind) else { return false } + return token.markerRanges.contains { escapedCharOffsets.contains($0.location) } + } + } + tokens.append(contentsOf: escapeTokens) + return tokens } diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index cfc4817..12788ba 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -17,10 +17,33 @@ extension NSAttributedString.Key { static let latexBounds = NSAttributedString.Key("LatexImageBounds") static let latexIsBlock = NSAttributedString.Key("LatexIsBlock") static let latexBlockOffsetY = NSAttributedString.Key("LatexBlockOffsetY") + static let thematicBreak = NSAttributedString.Key("ThematicBreak") + /// Int nesting level (1-based) of a blockquote line; the fragment + /// paints that many vertical bars in the left gutter. + static let blockquoteLevel = NSAttributedString.Key("BlockquoteLevel") + /// Marks a bullet-list marker char (`-`/`*`/`+`) whose glyph is hidden so + /// the fragment can paint a `•` in its place. Set to `true`. + static let bulletMarker = NSAttributedString.Key("BulletListMarker") + /// CGFloat — natural image width; presence flags block as overlay-rendered. + static let scrollableBlockNaturalWidth = NSAttributedString.Key("ScrollableBlockNaturalWidth") + /// Int — hash of source text; key for overlay reconcile + offset persistence. + static let scrollableBlockSourceID = NSAttributedString.Key("ScrollableBlockSourceID") + /// CGFloat — total reserved height (image + scroller strip) for overlay sizing. + static let scrollableBlockTotalHeight = NSAttributedString.Key("ScrollableBlockTotalHeight") + /// NSValue(range:) — full multi-line range of the wide-table source, used to scope width-change restyles. + static let scrollableBlockFullRange = NSAttributedString.Key("ScrollableBlockFullRange") } final class MarkdownTextLayoutFragment: NSTextLayoutFragment { + /// Horizontal space (points) each blockquote nesting level occupies — + /// shared so the styler's text indent and the painted bars line up. + static let blockquoteIndentPerLevel: CGFloat = 18 + static let blockquoteBarWidth: CGFloat = 3 + + /// Strip below an overlay block for the legacy-small scroller (~11pt) + buffer. + static let scrollableBlockScrollerStrip: CGFloat = 14 + // MARK: - FB15131180 /// Maps to TextKit-2's private `extraLineFragmentAttributes` selector so we can pin the trailing extra-line metrics to body font; otherwise a trailing heading paragraph inflates `usageBoundsForTextContainer` by ~30pt when the caret enters it. Pattern from STTextView. @@ -33,7 +56,7 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { /// and block images drawn below text via paragraphSpacing. override var renderingSurfaceBounds: CGRect { var bounds = super.renderingSurfaceBounds - if hasCodeBlockBackground { + if hasCodeBlockBackground || hasThematicBreak || hasBlockquote { let containerWidth = textLayoutManager?.textContainer?.size.width ?? bounds.width // Extend left to container edge bounds.origin.x = -layoutFragmentFrame.origin.x @@ -61,6 +84,16 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { // 4. Task checkboxes (on top of hidden [ ]/[x] markers) drawTaskCheckboxes(at: point, in: context) + + // 4b. Bullet glyphs (on top of hidden -/*/+ markers) + drawBulletMarkers(at: point, in: context) + + // 5. Thematic breaks (full-width line, painted last so it doesn't + // fight with anything that already drew at the line's center) + drawThematicBreaks(at: point, in: context) + + // 6. Blockquote bars (left gutter, behind nothing — text is indented) + drawBlockquoteBars(at: point, in: context) } // MARK: - Helpers @@ -128,6 +161,30 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { return isCodeBlockBackgroundColor(bgColor) } + private var hasThematicBreak: Bool { + guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return false } + var found = false + ts.enumerateAttribute(.thematicBreak, in: range, options: []) { value, _, stop in + if value as? Bool == true { + found = true + stop.pointee = true + } + } + return found + } + + private var hasBlockquote: Bool { + guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return false } + var found = false + ts.enumerateAttribute(.blockquoteLevel, in: range, options: []) { value, _, stop in + if value is Int { + found = true + stop.pointee = true + } + } + return found + } + private func drawCodeBlockBackground(at point: CGPoint, in context: CGContext) { guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return } @@ -240,16 +297,29 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { point: CGPoint ) -> CGRect? { guard let pos = drawPosition(forDocumentCharAt: attrRange.location, point: point) else { return nil } - let localIndex = attrRange.location - (fragmentNSRange?.location ?? 0) - let lb = lineBounds(forLocalIndex: localIndex, point: point) - let lineHeight = lb?.height ?? pos.lineHeight - let lineMinY = lb?.origin.y ?? (pos.baselineY - lineHeight) + let fragLocation = fragmentNSRange?.location ?? 0 + let localStart = attrRange.location - fragLocation + let localLast = max(localStart, localStart + attrRange.length - 1) + let firstLb = lineBounds(forLocalIndex: localStart, point: point) + // For a wrapped source span (e.g. a long `![alt](url)` that wraps in + // a narrow window), anchor to the LAST line's maxY so the image + // doesn't paint over subsequent wrapped lines of its own source. + let lastLb = lineBounds(forLocalIndex: localLast, point: point) ?? firstLb + let lineHeight = firstLb?.height ?? pos.lineHeight + let firstLineMinY = firstLb?.origin.y ?? (pos.baselineY - lineHeight) + let lastLineMaxY = (lastLb?.origin.y ?? firstLineMinY) + (lastLb?.height ?? lineHeight) let yPosition: CGFloat if let blockOffsetY { - yPosition = lineMinY + blockOffsetY + // Backward-compatible interpretation: `blockOffsetY` is the gap + // from the FIRST line's top to the image's top (= baseLineHeight + // + imageGap on a single-line source). Re-anchor to the last + // line by subtracting one line height, leaving the same single- + // line geometry intact while pushing the image down by one + // extra line per wrap. + yPosition = lastLineMaxY + blockOffsetY - lineHeight } else { - yPosition = lineMinY + (lineHeight - imageBounds.height) / 2 + yPosition = firstLineMinY + (lineHeight - imageBounds.height) / 2 } return CGRect(x: pos.x, y: yPosition, width: imageBounds.width, height: imageBounds.height) @@ -265,6 +335,10 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { guard value is NSImage else { return } let isBlock = ts.attribute(.latexIsBlock, at: attrRange.location, effectiveRange: nil) as? Bool ?? false guard isBlock else { return } + // Skip overlay blocks; surface bounds must stay within container. + if ts.attribute(.scrollableBlockNaturalWidth, at: attrRange.location, effectiveRange: nil) != nil { + return + } let boundsVal = ts.attribute(.latexBounds, at: attrRange.location, effectiveRange: nil) as? NSValue let imageBounds = boundsVal?.rectValue ?? .zero let blockOffsetY = ts.attribute(.latexBlockOffsetY, at: attrRange.location, effectiveRange: nil) as? CGFloat @@ -288,6 +362,11 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { ts.enumerateAttribute(.latexImage, in: range, options: []) { [weak self] value, attrRange, _ in guard let self, let image = value as? NSImage else { return } + // Skip overlay-rendered blocks; WideTableOverlay owns the visual. + if ts.attribute(.scrollableBlockNaturalWidth, at: attrRange.location, effectiveRange: nil) != nil { + return + } + let boundsVal = ts.attribute(.latexBounds, at: attrRange.location, effectiveRange: nil) as? NSValue let imageBounds = boundsVal?.rectValue ?? CGRect(origin: .zero, size: image.size) let isBlock = ts.attribute(.latexIsBlock, at: attrRange.location, effectiveRange: nil) as? Bool ?? false @@ -309,14 +388,160 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { } } + // MARK: - Thematic Breaks (---, ***, ___) + + /// Draw a 1pt horizontal rule across the full container width for any + /// line fragment whose backing text carries the `.thematicBreak` + /// attribute. This decouples HR rendering from the source-text length, + /// so a 3-char `---` looks the same as a 80-char auto-expanded line. + private func drawThematicBreaks(at point: CGPoint, in context: CGContext) { + guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return } + var hasThematic = false + ts.enumerateAttribute(.thematicBreak, in: range, options: []) { value, _, stop in + if value as? Bool == true { + hasThematic = true + stop.pointee = true + } + } + guard hasThematic else { return } + + let containerWidth = textLayoutManager?.textContainer?.size.width ?? layoutFragmentFrame.width + let theme = (textLayoutManager?.textContainer?.textView as? NativeTextView)? + .configuration.theme ?? .default + + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + let nsContext = NSGraphicsContext(cgContext: context, flipped: true) + NSGraphicsContext.current = nsContext + + let strokeColor = theme.strikethroughColor.withAlphaComponent(0.4) + strokeColor.setFill() + + // Walk each line fragment in this layout fragment and paint a + // band on those whose first character carries the marker. (HR + // tokens are always single-line, but the loop is robust if a + // future caller ever stacks several rules in one paragraph.) + let fragLocation = fragmentNSRange?.location ?? 0 + for lineFragment in textLineFragments { + let lr = lineFragment.characterRange + let docStart = fragLocation + lr.location + // TextKit 2 appends a synthetic trailing empty line fragment whose + // characterRange lands at exactly `tsLen` — `attribute(at:)` needs + // a strictly in-bounds index, so skip the sentinel. + guard docStart < ts.length else { continue } + let isHR = ts.attribute(.thematicBreak, at: docStart, effectiveRange: nil) as? Bool == true + let tb = lineFragment.typographicBounds + if isHR { + // tb.origin.y is already relative to this layout fragment. + let centerY = point.y + tb.origin.y + tb.height / 2 + let bandRect = CGRect( + x: point.x - layoutFragmentFrame.origin.x, + y: centerY - 0.5, + width: containerWidth, + height: 1 + ) + NSBezierPath(rect: bandRect).fill() + } + } + } + + // MARK: - Blockquote Bars + + /// Paint `level` vertical bars in the left gutter of every line that + /// carries `.blockquoteLevel`. Each line paints its own segment, so a + /// run of quote lines reads as one continuous bar. + private func drawBlockquoteBars(at point: CGPoint, in context: CGContext) { + guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return } + var anyLevel = false + ts.enumerateAttribute(.blockquoteLevel, in: range, options: []) { value, _, stop in + if value is Int { anyLevel = true; stop.pointee = true } + } + guard anyLevel else { return } + + let theme = (textLayoutManager?.textContainer?.textView as? NativeTextView)? + .configuration.theme ?? .default + let indentPerLevel = Self.blockquoteIndentPerLevel + let barWidth = Self.blockquoteBarWidth + + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + let nsContext = NSGraphicsContext(cgContext: context, flipped: true) + NSGraphicsContext.current = nsContext + theme.mutedText.withAlphaComponent(0.5).setFill() + + let fragLocation = fragmentNSRange?.location ?? 0 + let leftEdge = point.x - layoutFragmentFrame.origin.x + for lineFragment in textLineFragments { + let lr = lineFragment.characterRange + let docStart = fragLocation + lr.location + // TextKit 2 appends a synthetic trailing empty line fragment whose + // characterRange lands at exactly `tsLen` — `attribute(at:)` needs + // a strictly in-bounds index, so skip the sentinel. + guard docStart < ts.length else { continue } + let tb = lineFragment.typographicBounds + if let level = ts.attribute(.blockquoteLevel, at: docStart, effectiveRange: nil) as? Int { + // tb.origin.y is already relative to this layout fragment. + let barY = point.y + tb.origin.y + for i in 0..