From d222a704bcf473554fdb2edeaa685f92c272bf95 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Wed, 20 May 2026 13:50:54 +0200 Subject: [PATCH 1/8] Reapply "Merge xVanTuring's GFM feature branch + local bug fixes" This reverts commit 1988aac79b29450c8d2e8408831db9497f10ab82. --- .../Input/MarkdownListHandler.swift | 183 ++++++-- .../Parser/MarkdownDetection.swift | 22 + .../MarkdownEngine/Parser/MarkdownToken.swift | 11 + .../Parser/MarkdownTokenizer.swift | 232 +++++++++- .../Renderer/MarkdownTextLayoutFragment.swift | 174 +++++++- .../Styling/MarkdownStyler+Code.swift | 2 + .../Styling/MarkdownStyler+Images.swift | 98 +++++ .../Styling/MarkdownStyler+Latex.swift | 11 + .../Styling/MarkdownStyler+Tables.swift | 416 ++++++++++++++++++ .../MarkdownStyler+TaskCheckboxes.swift | 32 ++ .../Styling/MarkdownStyler+TextStyling.swift | 74 +++- .../Styling/MarkdownStyler.swift | 55 ++- .../NativeTextViewCoordinator+Restyling.swift | 7 +- ...tiveTextViewCoordinator+TextDelegate.swift | 25 +- .../NativeTextViewCoordinator.swift | 7 + .../TextView/NativeTextViewWrapper.swift | 23 +- .../MarkdownEngineDecouplingTests.swift | 46 ++ 17 files changed, 1365 insertions(+), 53 deletions(-) create mode 100644 Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift diff --git a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift index f33c2af..f3be22c 100644 --- a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift +++ b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift @@ -30,16 +30,94 @@ struct MarkdownLists { static let listRegex = try! NSRegularExpression( 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. + 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*"#) + /// Matches an optionally-indented `- `, `* ` or `+ ` at the start of a + /// line — the three CommonMark bullet markers, with leading spaces + /// and/or tabs (nested items). Used by `normalizeBulletMarkers` to + /// convert pasted/loaded bullets into the engine's canonical + /// `• ` form. + static let pasteableDashBulletRegex = try! NSRegularExpression( + pattern: #"^([ \t]*)[-+*] "#, + options: [.anchorsMatchLines] + ) + static func indentLevel(from leadingWhitespace: String) -> Int { let tabCount = leadingWhitespace.filter { $0 == "\t" }.count let spaceCount = leadingWhitespace.filter { $0 == " " }.count 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: - Storage Normalization + + /// Rewrite standard-Markdown bullets (`- foo`, `* foo`, `+ foo`) to the + /// engine's canonical bullet form (`\t• foo`) so pasted or + /// programmatically loaded markdown renders with the same hanging indent + /// and bullet glyph as bullets the user types directly. The typed-input + /// path already rewrites `-` → `\t• ` on space-after-dash; this closes + /// the gap for every other ingestion path. Code blocks are left + /// untouched. + static func normalizeBulletMarkers(_ text: String) -> String { + guard !text.isEmpty else { return text } + let nsText = text as NSString + let fullRange = NSRange(location: 0, length: nsText.length) + let matches = pasteableDashBulletRegex.matches(in: text, options: [], range: fullRange) + guard !matches.isEmpty else { return text } + // Parse code-block tokens once so per-match code-block lookups stay O(tokens), + // not O(parse) — pasting a huge document with many dash lines would + // otherwise tokenize once per match. + let codeTokens = text.contains("`") + ? MarkdownTokenizer.parseTokens(in: text).filter { $0.kind == .codeBlock || $0.kind == .inlineCode } + : [] + let mutable = NSMutableString(string: text) + // Walk in reverse so untouched offsets stay valid as we mutate. + for match in matches.reversed() { + let lineStart = match.range.location + if !codeTokens.isEmpty, + MarkdownDetection.isInsideCodeBlock(location: lineStart, codeTokens: codeTokens) { + continue + } + // Map the source indent (spaces and/or tabs) to a nesting + // depth and rewrite the whole ` ` prefix to the + // canonical `• `. depth+1 keeps the existing + // "top level = one tab" convention paragraphAttributes expects. + let ws = nsText.substring(with: match.range(at: 1)) + let depth = indentLevel(from: ws) + let canonical = String(repeating: "\t", count: depth + 1) + "• " + mutable.replaceCharacters(in: match.range, with: canonical) + } + return mutable as String + } + // MARK: - Paragraph Attributes for List Styling static func paragraphAttributes( @@ -100,7 +178,8 @@ struct MarkdownLists { // Bullet lists 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 +211,37 @@ struct MarkdownLists { let isInCodeBlock = textView.string.contains("`") ? MarkdownDetection.isInsideCodeBlock(location: affectedCharRange.location, in: textView.string) : false + + // BACKSPACE on an empty bullet prefix line ("\t+• " with no other + // content): undo the auto-conversion that turned a typed `-` + space + // into `\t• `. We collapse the whole prefix back to `-` so the user + // can keep deleting cleanly instead of stalling on the bullet glyph. + if listsEnabled, + replacementString.isEmpty, + affectedCharRange.length == 1, + !isInCodeBlock { + let nsText = textView.string as NSString + let safeLoc = min(affectedCharRange.location, nsText.length) + let lineRange = nsText.lineRange(for: NSRange(location: safeLoc, length: 0)) + // Strip trailing newline so the regex anchors against true end-of-line. + var contentLength = lineRange.length + if contentLength > 0, + nsText.character(at: lineRange.location + contentLength - 1) == 0x000A { + contentLength -= 1 + } + if contentLength > 0 { + let lineContentRange = NSRange(location: lineRange.location, length: contentLength) + let lineContent = nsText.substring(with: lineContentRange) + if lineContent.range(of: #"^\t+• $"#, options: .regularExpression) != nil, + affectedCharRange.location >= lineRange.location, + affectedCharRange.location < lineRange.location + contentLength { + MarkdownLists.performEdit(textView, replace: lineContentRange, with: "-") + textView.setSelectedRange(NSRange(location: lineRange.location + 1, length: 0)) + return false + } + } + } + if replacementString == ">" && affectedCharRange.length == 0 && !isInCodeBlock { let insertionLocation = affectedCharRange.location guard insertionLocation > 0 else { return true } @@ -241,29 +351,21 @@ struct MarkdownLists { } } - // 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) @@ -287,6 +389,37 @@ struct MarkdownLists { // Skip list continuation in code blocks guard listsEnabled && !isInCodeBlock else { return true } + + // Blockquote continuation: mirror the bullet-list behaviour. + // Pressing Enter on `> foo` adds a new `> ` line at the same + // nesting depth (`>>>` stays `>>>`); pressing Enter on an empty + // marker line strips the prefix so the user can exit the quote + // without backspacing through it. + 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 +427,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)) { 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..a2dac6d 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -17,10 +17,19 @@ 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") } 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 + // 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 +42,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 +70,13 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { // 4. Task checkboxes (on top of hidden [ ]/[x] markers) drawTaskCheckboxes(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 +144,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 +280,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) @@ -309,14 +362,117 @@ 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.. 0 else { return } let selectionRanges: [NSRange] = { guard let tv = textLayoutManager?.textContainer?.textView else { return [] } - let values = tv.selectedRanges as? [NSValue] ?? [] - return values.map { $0.rangeValue }.filter { $0.length > 0 } + return tv.selectedRanges.map { $0.rangeValue }.filter { $0.length > 0 } }() NSGraphicsContext.saveGraphicsState() diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Code.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Code.swift index ca113fc..fdb9acc 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Code.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Code.swift @@ -52,6 +52,8 @@ extension MarkdownStyler { .font: ctx.codeFont, .backgroundColor: ctx.codeBackgroundColor ])) + // Caret inside → show backticks at full size for editing; + // otherwise dim them to near-invisible. let inlineMarkerAttributes: [NSAttributedString.Key: Any] = isActive ? [ .foregroundColor: ctx.configuration.theme.mutedText, diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Images.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Images.swift index 3718675..34c0c3a 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Images.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Images.swift @@ -12,6 +12,104 @@ import Foundation extension MarkdownStyler { + // MARK: Markdown Image Links ![alt](url) + + /// Style standalone `![alt](url)` paragraphs by routing the URL through + /// the embedder's `EmbeddedImageProvider` (URL goes into the request's + /// `name` field — providers that don't speak URLs simply return `nil`, + /// at which point we fall back to dimming the markdown source). + static func styleImageLinks(_ ctx: StylingContext) -> [StyledRange] { + var attrs: [StyledRange] = [] + for (idx, token) in ctx.tokens.enumerated() where token.kind == .imageLink { + if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue } + + // The URL lives between markerRanges[2] ('(') and markerRanges[3] (')'). + guard token.markerRanges.count >= 4 else { + appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme) + continue + } + let openParen = token.markerRanges[2] + let closeParen = token.markerRanges[3] + let urlStart = NSMaxRange(openParen) + let urlLength = closeParen.location - urlStart + guard urlLength > 0 else { + appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme) + continue + } + let urlRange = NSRange(location: urlStart, length: urlLength) + let url = ctx.nsText.substring(with: urlRange) + let isActive = ctx.activeTokenIndices.contains(idx) + + let request = EmbeddedImageRequest(name: url) + guard let image = ctx.services.images.image(for: request) else { + appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme) + continue + } + + let imageEmbedConfig = ctx.configuration.imageEmbed + let maxWidth: CGFloat = { + if let tc = ctx.layoutBridge?.firstTextContainer { + let w = tc.containerSize.width - tc.lineFragmentPadding * 2 + if w > 0 && w < imageEmbedConfig.unreasonableMaxWidth { return w } + } + return imageEmbedConfig.fallbackMaxWidth + }() + + let minWidth = imageEmbedConfig.minimumWidth + let imageSize = image.size + let targetWidth = min(max(imageSize.width, minWidth), maxWidth) + let scale = imageSize.width > 0 ? targetWidth / imageSize.width : 1 + let displayWidth = imageSize.width * scale + let displayHeight = imageSize.height * scale + let imageBounds = CGRect(x: 0, y: 0, width: displayWidth, height: displayHeight) + + let rawContent = ctx.nsText.substring(with: token.range) + let rendered: Bool + if isActive { + rendered = appendRenderedStandaloneBlock( + for: token, + rawContent: rawContent, + image: image, + imageBounds: imageBounds, + paragraphSpacingBefore: imageEmbedConfig.paragraphSpacing, + paragraphSpacing: imageEmbedConfig.paragraphSpacing, + alignment: .left, + mode: .visibleSource(imageGap: imageEmbedConfig.imageGap), + ctx: ctx, + attrs: &attrs + ) + } else { + rendered = appendRenderedStandaloneBlock( + for: token, + rawContent: rawContent, + image: image, + imageBounds: imageBounds, + paragraphSpacingBefore: imageEmbedConfig.paragraphSpacing, + paragraphSpacing: imageEmbedConfig.paragraphSpacing, + alignment: .left, + mode: .collapsedSource(markerTexts: ["![", "]", "(", ")"]), + ctx: ctx, + attrs: &attrs + ) + if rendered { + // The standalone helper hides the alt text + the four + // markers, but the URL between '(' and ')' is its own + // range and stays visible unless we collapse it too. + let urlText = ctx.nsText.substring(with: urlRange) + attrs.append((urlRange, [ + .foregroundColor: NSColor.clear, + .font: ctx.latexMarkerFont, + .kern: -HeadingHelpers.textWidth(urlText, font: ctx.latexMarkerFont) + ])) + } + } + if !rendered { + appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme) + } + } + return attrs + } + // MARK: Image Embeds ![[Name]] static func styleImageEmbeds(_ ctx: StylingContext) -> [StyledRange] { diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift index cc604ec..90684bd 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift @@ -61,8 +61,19 @@ extension MarkdownStyler { static func styleInlineLatex(_ ctx: StylingContext) -> [StyledRange] { var attrs: [StyledRange] = [] + // Tables render their own cell contents (including `$…$`) into a single + // image via `formattedCellString` + `collapsedSource`. If we also tag + // the source-text `$x^2$` with a `.latexImage` attribute, the renderer + // draws that tiny inline image on the collapsed 1pt source line under + // the table — visible as a stray dot. Skip inline LaTeX inside a + // table; the table image already covers it. + let tableRanges = ctx.tokens.filter { $0.kind == .table }.map(\.range) for (idx, token) in ctx.tokens.enumerated() where token.kind == .inlineLatex { if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue } + if tableRanges.contains(where: { tableRange in + token.range.location >= tableRange.location + && NSMaxRange(token.range) <= NSMaxRange(tableRange) + }) { continue } attrs.append((token.range, [NSAttributedString.Key.spellingState: 0])) diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift new file mode 100644 index 0000000..19151ab --- /dev/null +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -0,0 +1,416 @@ +// +// MarkdownStyler+Tables.swift +// MarkdownEngine +// +// GFM tables. The block is rendered to a single NSImage and emitted via +// the same collapsedSource path block-LaTeX uses, so the source stays +// in sync with the document but the user only sees the rendered grid +// when the caret is outside the table. +// + +import AppKit +import Foundation + +extension MarkdownStyler { + + enum TableAlignment { + case left + case center + case right + } + + struct ParsedTable { + let header: [String] + let alignments: [TableAlignment] + let rows: [[String]] + } + + static func styleTables(_ ctx: StylingContext) -> [StyledRange] { + var attrs: [StyledRange] = [] + for (idx, token) in ctx.tokens.enumerated() where token.kind == .table { + // The tokenizer already drops table matches that overlap a + // fenced code block, so we don't re-check that here. (The + // generic isInsideCodeBlock helper also flags overlap with + // inline code, which would falsely reject any table that + // contains a `…` cell.) + attrs.append((token.range, [.spellingState: 0])) + + let source = ctx.nsText.substring(with: token.range) + guard let parsed = parseTableSource(source) else { continue } + + let isActive = ctx.activeTokenIndices.contains(idx) + if isActive { + // Caret inside the table — show source so the user can + // edit. We mute pipes so the structure stays legible + // without dominating, matching how the rest of the engine + // dims syntax characters. + let muted = ctx.configuration.theme.mutedText + let body = ctx.configuration.theme.bodyText + attrs.append((token.range, [.foregroundColor: body, .font: ctx.baseFont])) + if let pipeRegex = try? NSRegularExpression(pattern: "\\|") { + for m in pipeRegex.matches(in: ctx.text, options: [], range: token.range) { + attrs.append((m.range, [.foregroundColor: muted])) + } + } + continue + } + + let image = renderTable( + parsed, + baseFont: ctx.baseFont, + theme: ctx.configuration.theme, + codeBackgroundColor: ctx.codeBackgroundColor, + latex: ctx.services.latex + ) + let imageBounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) + _ = appendRenderedStandaloneBlock( + for: token, + rawContent: source, + image: image, + imageBounds: imageBounds, + paragraphSpacingBefore: ctx.baseDefaultLineHeight * 0.5, + paragraphSpacing: ctx.baseDefaultLineHeight * 0.5, + alignment: .left, + mode: .collapsedSource(markerTexts: []), + ctx: ctx, + attrs: &attrs + ) + } + return attrs + } + + // MARK: - Parsing + + static func parseTableSource(_ source: String) -> ParsedTable? { + let rawLines = source.components(separatedBy: CharacterSet.newlines) + let lines = rawLines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + guard lines.count >= 2 else { return nil } + + let header = parseTableRow(lines[0]) + let alignments = parseTableAlignments(lines[1]) + guard !header.isEmpty, !alignments.isEmpty else { return nil } + + let columnCount = max(header.count, alignments.count) + let bodyLines = Array(lines.dropFirst(2)) + + func pad(_ array: [T], to count: Int, with fill: T) -> [T] { + if array.count == count { return array } + if array.count > count { return Array(array.prefix(count)) } + return array + Array(repeating: fill, count: count - array.count) + } + + let paddedHeader = pad(header, to: columnCount, with: "") + let paddedAlign = pad(alignments, to: columnCount, with: .left) + let rows = bodyLines.map { pad(parseTableRow($0), to: columnCount, with: "") } + + return ParsedTable(header: paddedHeader, alignments: paddedAlign, rows: rows) + } + + private static func parseTableRow(_ line: String) -> [String] { + var s = line.trimmingCharacters(in: .whitespaces) + if s.hasPrefix("|") { s.removeFirst() } + if s.hasSuffix("|") { s.removeLast() } + return s.split(separator: "|", omittingEmptySubsequences: false).map { + $0.trimmingCharacters(in: .whitespaces) + } + } + + private static func parseTableAlignments(_ line: String) -> [TableAlignment] { + let cells = parseTableRow(line) + return cells.map { cell in + let trimmed = cell.trimmingCharacters(in: .whitespaces) + let leading = trimmed.hasPrefix(":") + let trailing = trimmed.hasSuffix(":") + switch (leading, trailing) { + case (true, true): return .center + case (false, true): return .right + default: return .left + } + } + } + + // MARK: - Inline-formatted cell strings + + /// Convert a raw cell string (which may contain inline markdown like + /// `**bold**`, `*italic*`, `` `code` ``, `~~strike~~`, `$math$`) into an + /// `NSAttributedString`. Markers themselves are stripped so the rendered + /// table image only shows the formatted result. LaTeX spans become + /// `NSTextAttachment` images so the math metrics flow through to + /// column-width measurement. Header cells start out bold. + private static func formattedCellString( + _ raw: String, + baseFont: NSFont, + header: Bool, + theme: MarkdownEditorTheme, + codeBackgroundColor: NSColor, + latex: any LatexRenderer + ) -> NSAttributedString { + let descriptor = baseFont.fontDescriptor + let pointSize = baseFont.pointSize + let regularFont = baseFont + let boldFont = NSFont(descriptor: descriptor.withSymbolicTraits(.bold), size: pointSize) ?? baseFont + let italicFont = NSFont(descriptor: descriptor.withSymbolicTraits(.italic), size: pointSize) ?? baseFont + let boldItalicFont = NSFont(descriptor: descriptor.withSymbolicTraits([.bold, .italic]), size: pointSize) ?? boldFont + let codeFont = NSFont.monospacedSystemFont(ofSize: pointSize, weight: .regular) + + let baseAttrs: [NSAttributedString.Key: Any] = [ + .font: header ? boldFont : regularFont, + .foregroundColor: theme.bodyText + ] + + let result = NSMutableAttributedString(string: raw, attributes: baseAttrs) + + // For each pattern: find matches, and for each match (in reverse so + // earlier offsets stay stable), strip the markers and apply attrs + // on the inner content. The `attrsForCurrentFont` closure receives + // the font already set on the inner range so we can compose + // bold-on-italic, italic-on-bold, etc. + func applyPattern( + _ pattern: String, + prefix: Int, + suffix: Int, + attrsForCurrentFont: (NSFont) -> [NSAttributedString.Key: Any] + ) { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return } + let scan = result.string as NSString + let matches = regex.matches(in: result.string, range: NSRange(location: 0, length: scan.length)) + for m in matches.reversed() { + let full = m.range + let inner = NSRange(location: full.location + prefix, length: full.length - prefix - suffix) + guard inner.length > 0, + inner.location >= 0, + inner.location + inner.length <= result.length else { continue } + let currentFont = (result.attribute(.font, at: inner.location, effectiveRange: nil) as? NSFont) ?? regularFont + let innerString = (result.string as NSString).substring(with: inner) + let replacement = NSMutableAttributedString(string: innerString) + // Carry over existing attributes on the inner range so + // already-applied formatting (e.g. inline-code processed + // earlier) survives the marker strip. + result.enumerateAttributes(in: inner, options: []) { existing, range, _ in + let local = NSRange(location: range.location - inner.location, length: range.length) + replacement.addAttributes(existing, range: local) + } + replacement.addAttributes( + attrsForCurrentFont(currentFont), + range: NSRange(location: 0, length: replacement.length) + ) + result.replaceCharacters(in: full, with: replacement) + } + } + + // LaTeX first — replace each `$...$` with an inline image so the + // markers and content disappear from later passes. We use + // `NSTextAttachment` so column-width measurement and drawing both + // pick up the image's intrinsic size and baseline offset. + if let latexRegex = try? NSRegularExpression(pattern: #"\$([^$]+)\$"#) { + let scan = result.string as NSString + let matches = latexRegex.matches(in: result.string, range: NSRange(location: 0, length: scan.length)) + for m in matches.reversed() { + let full = m.range + let inner = NSRange(location: full.location + 1, length: full.length - 2) + guard inner.length > 0 else { continue } + let latexContent = (result.string as NSString).substring(with: inner) + guard let entry = latex.render(latex: latexContent, fontSize: pointSize, theme: theme) else { continue } + let attachment = NSTextAttachment() + attachment.image = entry.image + attachment.bounds = CGRect( + x: 0, + y: entry.baselineOffset, + width: entry.size.width, + height: entry.size.height + ) + let replacement = NSAttributedString(attachment: attachment) + result.replaceCharacters(in: full, with: replacement) + } + } + + // Inline code next so its content can't be re-interpreted. + applyPattern(#"`([^`]+)`"#, prefix: 1, suffix: 1) { _ in + [ + .font: codeFont, + .backgroundColor: codeBackgroundColor + ] + } + applyPattern(#"~~([^~]+)~~"#, prefix: 2, suffix: 2) { _ in + [ + .strikethroughStyle: NSUnderlineStyle.single.rawValue, + .strikethroughColor: theme.bodyText + ] + } + applyPattern(#"\*\*\*([^*]+)\*\*\*"#, prefix: 3, suffix: 3) { _ in + [.font: boldItalicFont] + } + applyPattern(#"\*\*([^*]+)\*\*"#, prefix: 2, suffix: 2) { current in + current.fontDescriptor.symbolicTraits.contains(.italic) + ? [.font: boldItalicFont] + : [.font: boldFont] + } + applyPattern(#"\*([^*]+)\*"#, prefix: 1, suffix: 1) { current in + current.fontDescriptor.symbolicTraits.contains(.bold) + ? [.font: boldItalicFont] + : [.font: italicFont] + } + return result + } + + // MARK: - Rendering + + private static func renderTable( + _ table: ParsedTable, + baseFont: NSFont, + theme: MarkdownEditorTheme, + codeBackgroundColor: NSColor, + latex: any LatexRenderer + ) -> NSImage { + let columnCount = table.alignments.count + let cellHPadding: CGFloat = 12 + let cellVPadding: CGFloat = 6 + let borderWidth: CGFloat = 1 + let borderColor = theme.mutedText.withAlphaComponent(0.5) + let baseLineHeight: CGFloat = ceil(baseFont.ascender - baseFont.descender + baseFont.leading) + let minColumnContentWidth: CGFloat = 16 + + // Pre-format every cell so column-width measurement and drawing + // both use the same NSAttributedString (incl. bold/italic/code + // metrics + LaTeX attachment sizes). + let headerCells = table.header.map { + formattedCellString( + $0, baseFont: baseFont, header: true, theme: theme, + codeBackgroundColor: codeBackgroundColor, latex: latex + ) + } + let bodyCells = table.rows.map { row in + row.map { + formattedCellString( + $0, baseFont: baseFont, header: false, theme: theme, + codeBackgroundColor: codeBackgroundColor, latex: latex + ) + } + } + + var columnWidths = [CGFloat](repeating: minColumnContentWidth, count: columnCount) + var maxCellHeight: CGFloat = baseLineHeight + func considerCell(_ cell: NSAttributedString, col: Int) { + let size = cell.size() + columnWidths[col] = max(columnWidths[col], ceil(size.width)) + maxCellHeight = max(maxCellHeight, ceil(size.height)) + } + for (i, cell) in headerCells.enumerated() where i < columnCount { + considerCell(cell, col: i) + } + for row in bodyCells { + for (i, cell) in row.enumerated() where i < columnCount { + considerCell(cell, col: i) + } + } + + let lineHeight = max(baseLineHeight, maxCellHeight) + let rowCount = 1 + table.rows.count // header + body rows + let totalWidth = columnWidths.reduce(0, +) + + CGFloat(columnCount) * 2 * cellHPadding + + CGFloat(columnCount + 1) * borderWidth + let rowHeight = lineHeight + 2 * cellVPadding + let totalHeight = CGFloat(rowCount) * rowHeight + CGFloat(rowCount + 1) * borderWidth + + let size = NSSize(width: totalWidth, height: totalHeight) + + // Pre-compute layout offsets (top-down coords; the drawing handler + // runs in a flipped context so this reads naturally). + var columnLeft = [CGFloat](repeating: 0, count: columnCount + 1) + columnLeft[0] = borderWidth + for i in 0..[ ]` range on `location`'s line if + /// `location` sits inside (or right at the trailing edge of) a task-list + /// syntax region, else `nil`. The styler intentionally suppresses the + /// checkbox glyph while the caret is inside this region so the user can + /// edit raw chars; the coordinator uses this to detect crossings and + /// trigger a restyle when the caret enters/leaves. + static func taskSyntaxRange(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) + let match = taskListRegex.firstMatch( + in: line, + options: [], + range: NSRange(location: 0, length: line.utf16.count) + ) + guard let match else { return nil } + let markerLineRange = match.range(at: 2) + let checkboxLineRange = match.range(at: 4) + guard markerLineRange.location != NSNotFound, + checkboxLineRange.location != NSNotFound else { return nil } + let syntaxStart = lineRange.location + markerLineRange.location + let syntaxEnd = lineRange.location + checkboxLineRange.location + checkboxLineRange.length + let syntaxRange = NSRange(location: syntaxStart, length: syntaxEnd - syntaxStart) + if NSLocationInRange(location, syntaxRange) || location == syntaxEnd { + return syntaxRange + } + return nil + } + // MARK: Task List Checkboxes static func styleTaskCheckboxes(_ ctx: StylingContext) -> [StyledRange] { diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+TextStyling.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+TextStyling.swift index c3b469f..bddce84 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+TextStyling.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+TextStyling.swift @@ -45,6 +45,52 @@ extension MarkdownStyler { return attrs } + // MARK: Blockquotes + + static func styleBlockquotes(_ ctx: StylingContext) -> [StyledRange] { + var attrs: [StyledRange] = [] + let indentPerLevel = MarkdownTextLayoutFragment.blockquoteIndentPerLevel + for (idx, token) in ctx.tokens.enumerated() where token.kind == .blockquote { + guard let markerRange = token.markerRanges.first else { continue } + let markerSub = ctx.nsText.substring(with: markerRange) + let level = max(1, markerSub.filter { $0 == ">" }.count) + + // Indent the line so the text clears the drawn bar(s). + let textIndent = CGFloat(level) * indentPerLevel + indentPerLevel * 0.5 + let para = NSMutableParagraphStyle() + para.firstLineHeadIndent = textIndent + para.headIndent = textIndent + para.minimumLineHeight = ctx.baseDefaultLineHeight + para.maximumLineHeight = ctx.baseDefaultLineHeight + para.paragraphSpacing = 0 + para.paragraphSpacingBefore = 0 + attrs.append((ctx.nsText.paragraphRange(for: token.range), [.paragraphStyle: para])) + + // Quoted text reads muted; bold/code inside keep their own font. + if token.contentRange.length > 0 { + attrs.append((token.contentRange, [.foregroundColor: ctx.configuration.theme.mutedText])) + } + + // Markers: revealed (muted) while editing this line, otherwise + // collapsed so only the painted bar shows. + let isActive = ctx.activeTokenIndices.contains(idx) + if isActive { + attrs.append((markerRange, [.foregroundColor: ctx.configuration.theme.mutedText])) + } else { + attrs.append((markerRange, [ + .foregroundColor: NSColor.clear, + .font: ctx.inlineMarkerFont + ])) + } + + // Tell the layout fragment how many bars to paint on this line. + attrs.append((NSRange(location: token.range.location, length: 1), [ + .blockquoteLevel: level + ])) + } + return attrs + } + // MARK: Bold / Italic / Bold+Italic static func styleEmphasis(_ ctx: StylingContext) -> [StyledRange] { @@ -52,6 +98,21 @@ extension MarkdownStyler { let len = ctx.nsText.length guard len > 0 else { return [] } + // Skip emphasis only when it is FULLY contained in a code token + // (fenced block or `…` span). Mere overlap must NOT suppress it, + // so a span that CONTAINS inline code (e.g. `**bold `c`**`, + // `~~strike `c`~~`) still styles. Replaces upstream's overlap + // `isInsideCodeBlock` check (our fix d8644fa). + func isFullyInsideAnyCode(_ range: NSRange) -> Bool { + for codeToken in ctx.codeTokens { + if range.location >= codeToken.range.location + && NSMaxRange(range) <= NSMaxRange(codeToken.range) { + return true + } + } + return false + } + var traits = [UInt8](repeating: 0, count: len) let boldBit: UInt8 = 1 let italicBit: UInt8 = 2 @@ -64,7 +125,7 @@ extension MarkdownStyler { case .boldItalic: mask = boldBit | italicBit default: continue } - if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue } + if isFullyInsideAnyCode(token.range) { continue } let r = token.contentRange let upper = min(r.location + r.length, len) for i in max(r.location, 0).. [StyledRange] { var attrs: [StyledRange] = [] - let hrPattern = "^[ \\t]*-{3,}[ \\t]*$" + // CommonMark thematic break: a line of 3+ matching `-`, `*`, or `_`, + // optional surrounding whitespace. + let hrPattern = #"^[ \t]*(-{3,}|\*{3,}|_{3,})[ \t]*$"# if let hrRegex = try? NSRegularExpression(pattern: hrPattern, options: [.anchorsMatchLines]) { for hrMatch in hrRegex.matches(in: ctx.text, range: ctx.fullRange) { - attrs.append((hrMatch.range, [.foregroundColor: NSColor.clear])) + // Don't render the rule while the caret is sitting on this + // line. Otherwise typing the third `-` would instantly hide + // the source under a full-width rule, leaving the cursor at + // a now-invisible source-text position and tripping the + // layout pass on the next Enter (the visible HR fragment's + // geometry suddenly has to absorb a newline at a slot the + // user can't see). Once the caret leaves the line — i.e. on + // Enter — the rule renders normally. + let caretIsOnHRLine = + NSLocationInRange(ctx.caretLocation, hrMatch.range) + || ctx.caretLocation == NSMaxRange(hrMatch.range) + if caretIsOnHRLine { continue } + // Hide the source chars and tag the range so the layout + // fragment can paint a full-width rule. The previous + // implementation used a thick strikethrough across the + // matched chars; that worked only when an enter-handler + // had auto-expanded `---` to fill the container width, + // and never worked at all for `***`/`___`. With a + // dedicated marker the rule is always container-wide + // regardless of how many chars are in the source. attrs.append((hrMatch.range, [ - .strikethroughStyle: NSUnderlineStyle.thick.rawValue, - .strikethroughColor: ctx.configuration.theme.strikethroughColor + .foregroundColor: NSColor.clear, + .thematicBreak: true ])) let rulePara = NSMutableParagraphStyle() attrs.append((hrMatch.range, [.paragraphStyle: rulePara])) @@ -341,6 +365,27 @@ extension MarkdownStyler { return attrs } + /// Returns the line range if `location` sits on a thematic-break line + /// (a line of 3+ matching `-`, `*`, or `_` with optional surrounding + /// whitespace), else `nil`. The coordinator uses this to trigger a + /// restyle on caret crossings in/out of an HR line — HRs are styled + /// via a pure attribute (no `MarkdownToken`), so `tokensChanged` + /// alone doesn't catch these crossings. + static func hrLineRange(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) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard line.range( + of: #"^[ \t]*(-{3,}|\*{3,}|_{3,})[ \t]*$"#, + options: .regularExpression + ) != nil else { + return nil + } + return lineRange + } + // MARK: Incomplete Link Brackets static func styleIncompleteLinkBrackets(_ ctx: StylingContext) -> [StyledRange] { diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index 8b56f11..2977447 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -19,7 +19,12 @@ extension NativeTextViewCoordinator { from text: String, invalidateLayout: Bool = false ) { - let displayState = WikiLinkService.makeDisplayState(from: text) + // Canonicalize standard-Markdown bullets (`- `/`* `/`+ `, incl. + // space-indented nested) to the engine's `\t• ` form for display. + // lastSyncedText stays the ORIGINAL text so the binding-change + // guard in updateNSView keeps working. + let normalizedInput = MarkdownLists.normalizeBulletMarkers(text) + let displayState = WikiLinkService.makeDisplayState(from: normalizedInput) let displayText = displayState.display wikiLinkMetadata = displayState.metadata diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 5a68e32..99f3dd1 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -215,9 +215,32 @@ extension NativeTextViewCoordinator { let shouldSkipSelectionRestyle = pendingEditedRange != nil let tokensChanged = activeTokenIndices != prevActive + // Caret crossings in/out of `- [ ]` syntax need a restyle too: task + // checkboxes aren't tracked as tokens, so `tokensChanged` won't + // notice them, but the styler suppresses the checkbox glyph while + // the caret sits inside the syntax. Without this signal a + // cursor-out (after editing the brackets) leaves the line stuck on + // raw chars. + let prevTaskSyntax = previousCaretLocation.flatMap { + MarkdownStyler.taskSyntaxRange(at: $0, in: tv.string) + } + let currentTaskSyntax = MarkdownStyler.taskSyntaxRange(at: selLoc, in: tv.string) + let taskSyntaxChanged = prevTaskSyntax?.location != currentTaskSyntax?.location + || prevTaskSyntax?.length != currentTaskSyntax?.length + // Caret crossings in/out of a thematic-break (HR) line also need a + // restyle: HR rendering is a pure attribute (no MarkdownToken), so + // `tokensChanged` won't notice when the caret enters/leaves an + // `---` / `***` / `___` line. Without this, clicking on a rendered + // HR wouldn't reveal the source dashes for editing. + let prevHRLine = previousCaretLocation.flatMap { + MarkdownStyler.hrLineRange(at: $0, in: tv.string) + } + let currentHRLine = MarkdownStyler.hrLineRange(at: selLoc, in: tv.string) + let hrLineChanged = prevHRLine?.location != currentHRLine?.location + || prevHRLine?.length != currentHRLine?.length if shouldSkipSelectionRestyle { // textDidChange performs the pending restyle for this edit cycle. - } else if tokensChanged { + } else if tokensChanged || taskSyntaxChanged || hrLineChanged { restyleTextView(tv, paragraphCandidates: paragraphCandidates, tokens: tokens) } diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index e95380e..74434e3 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -29,6 +29,13 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { subscribeToAppearanceNotification() } } + /// Last `EmbeddedImageProvider.fingerprint()` value we've reflected in + /// the textView's attributes. We cache it here because embedders that + /// MUTATE the same provider over time (async URL fetches, etc.) would + /// otherwise fool the wrapper's "did the embedder hand us a new + /// fingerprint" check — re-reading the same object twice always + /// returns the current value, regardless of when state changed. + var lastImageFingerprint: AnyHashable? private var busObservers: [NSObjectProtocol] = [] private var registeredAppearanceObserverName: Notification.Name? weak var textView: NSTextView? diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index c7a49c1..d3b51fd 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -148,7 +148,8 @@ public struct NativeTextViewWrapper: NSViewRepresentable { textView.isEditable = isEditable textView.isSelectable = isEditable textView.isRichText = true - let initialState = WikiLinkService.makeDisplayState(from: text) + let normalizedInput = MarkdownLists.normalizeBulletMarkers(text) + let initialState = WikiLinkService.makeDisplayState(from: normalizedInput) textView.string = initialState.display textView.delegate = context.coordinator textView.isVerticallyResizable = true @@ -264,10 +265,25 @@ public struct NativeTextViewWrapper: NSViewRepresentable { // (e.g. when the available wiki-link targets change). Cheap pointer-/ // value-based comparison; full equality isn't required because the // embedder is the source of truth. - if context.coordinator.configuration.services.images.fingerprint() - != configuration.services.images.fingerprint() { + let newImageFingerprint = configuration.services.images.fingerprint() + if newImageFingerprint != context.coordinator.lastImageFingerprint { + context.coordinator.lastImageFingerprint = newImageFingerprint context.coordinator.configuration.services = configuration.services (nsView.documentView as? NativeTextView)?.configuration.services = configuration.services + // Force the rest of updateNSView to re-run styling — without this + // the early-return below short-circuits when text/font are + // unchanged, and a freshly fetched async image (the typical + // fingerprint trigger) would never get drawn. + context.coordinator.didInitialFormatting = false + // TextKit 2 caches layout fragments and only auto-invalidates on + // text changes. Custom image attributes (`.latexImage`, + // `.latexIsBlock`, …) won't trip the layout pass on their own, + // so the cached `renderingSurfaceBounds` would still reflect a + // pre-image height. Force a layout invalidation to pick up the + // new image rects when re-styling re-attaches them. + if let tlm = textView.textLayoutManager { + tlm.invalidateLayout(for: tlm.documentRange) + } } textView.isEditable = isEditable textView.isSelectable = isEditable @@ -350,6 +366,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { ) coordinator.documentId = documentId coordinator.configuration = configuration + coordinator.lastImageFingerprint = configuration.services.images.fingerprint() coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange coordinator.userPrefersContinuousSpellChecking = configuration.spellChecking.continuousSpellChecking coordinator.userPrefersGrammarChecking = configuration.spellChecking.grammarChecking diff --git a/Tests/MarkdownEngineTests/MarkdownEngineDecouplingTests.swift b/Tests/MarkdownEngineTests/MarkdownEngineDecouplingTests.swift index 1ed30f5..373824d 100644 --- a/Tests/MarkdownEngineTests/MarkdownEngineDecouplingTests.swift +++ b/Tests/MarkdownEngineTests/MarkdownEngineDecouplingTests.swift @@ -69,6 +69,20 @@ struct MarkdownEngineDecouplingTests { #expect(kinds.contains(.inlineCode)) } + @Test func tokenizerParsesStrikethrough() { + let tokens = MarkdownTokenizer.parseTokens(in: "before ~~deleted~~ after") + let strike = tokens.first { $0.kind == .strikethrough } + #expect(strike != nil) + #expect(strike?.markerRanges.count == 2) + #expect(strike?.markerRanges.first?.length == 2) + #expect(strike?.markerRanges.last?.length == 2) + } + + @Test func tokenizerDoesNotMatchTripleTilde() { + let tokens = MarkdownTokenizer.parseTokens(in: "~~~not strikethrough~~~") + #expect(!tokens.contains { $0.kind == .strikethrough }) + } + // MARK: Default services container is fully wired with no-ops @Test func defaultServicesAreAllNoOps() { @@ -90,6 +104,38 @@ struct MarkdownEngineDecouplingTests { #expect(bus.findClearHighlights == nil) } + // MARK: Bullet normalization + + @Test func dashBulletNormalizesToCanonicalBullet() { + let input = "- 你好\nplain line\n- second" + let output = MarkdownLists.normalizeBulletMarkers(input) + #expect(output == "\t• 你好\nplain line\n\t• second") + } + + @Test func tabIndentedDashBulletKeepsItsDepth() { + let input = "- top\n\t- nested\n\t\t- deeper" + let output = MarkdownLists.normalizeBulletMarkers(input) + #expect(output == "\t• top\n\t\t• nested\n\t\t\t• deeper") + } + + @Test func horizontalRuleIsNotNormalizedAsBullet() { + let input = "before\n---\nafter" + let output = MarkdownLists.normalizeBulletMarkers(input) + #expect(output == input) + } + + @Test func dashInsideFencedCodeBlockIsLeftAlone() { + let input = "```\n- not a bullet\n```\n- real bullet" + let output = MarkdownLists.normalizeBulletMarkers(input) + #expect(output == "```\n- not a bullet\n```\n\t• real bullet") + } + + @Test func textWithoutDashBulletsIsReturnedUntouched() { + let input = "no bullets here\nstill nothing" + let output = MarkdownLists.normalizeBulletMarkers(input) + #expect(output == input) + } + // MARK: Styler runs end-to-end with defaults @Test func stylerProducesAttributesWithDefaultServices() { From df38a361500c115adbbb67b32afa366a3ed14c72 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Thu, 21 May 2026 10:09:34 +0200 Subject: [PATCH 2/8] image paste bug fixed --- .../MarkdownEngine/Styling/MarkdownStyler.swift | 7 +++++-- .../TextView/NativeTextViewWrapper.swift | 17 ++++++----------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift index 7df07bf..ddaff01 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift @@ -418,9 +418,12 @@ extension MarkdownStyler { if token.kind == .codeBlock || token.kind == .inlineCode || token.kind == .inlineLatex || token.kind == .imageEmbed { continue } - if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { - continue + // Containment, not overlap — so a strike that wraps inline code isn't skipped. + let isFullyInsideCode = ctx.codeTokens.contains { codeToken in + token.range.location >= codeToken.range.location + && NSMaxRange(token.range) <= NSMaxRange(codeToken.range) } + if isFullyInsideCode { continue } let smallSize = ctx.configuration.markers.hiddenMarkerFontSize let smallFont = NSFont(name: ctx.fontName, size: smallSize) ?? NSFont.systemFont(ofSize: smallSize) if token.kind == .link && token.markerRanges.count >= 4 { diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index d3b51fd..b521596 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -270,20 +270,15 @@ public struct NativeTextViewWrapper: NSViewRepresentable { context.coordinator.lastImageFingerprint = newImageFingerprint context.coordinator.configuration.services = configuration.services (nsView.documentView as? NativeTextView)?.configuration.services = configuration.services - // Force the rest of updateNSView to re-run styling — without this - // the early-return below short-circuits when text/font are - // unchanged, and a freshly fetched async image (the typical - // fingerprint trigger) would never get drawn. - context.coordinator.didInitialFormatting = false - // TextKit 2 caches layout fragments and only auto-invalidates on - // text changes. Custom image attributes (`.latexImage`, - // `.latexIsBlock`, …) won't trip the layout pass on their own, - // so the cached `renderingSurfaceBounds` would still reflect a - // pre-image height. Force a layout invalidation to pick up the - // new image rects when re-styling re-attaches them. + // Invalidate layout so custom image attributes re-measure. if let tlm = textView.textLayoutManager { tlm.invalidateLayout(for: tlm.documentRange) } + // Restyle live tv content — full rebuild would clobber paste-fresh embeds when `text` binding hasn't caught up. + let fullRange = NSRange(location: 0, length: (textView.string as NSString).length) + if fullRange.length > 0 { + context.coordinator.restyleParagraphs([fullRange], in: textView) + } } textView.isEditable = isEditable textView.isSelectable = isEditable From c7dd29460e6c2a33010b8f27112fffb2a5259c5b Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Thu, 21 May 2026 11:45:14 +0200 Subject: [PATCH 3/8] Update table paste --- Sources/MarkdownEngine/Styling/MarkdownStyler.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift index ddaff01..f7eb2f2 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift @@ -222,7 +222,9 @@ extension MarkdownStyler { para.minimumLineHeight = neededHeight para.maximumLineHeight = max(para.maximumLineHeight, neededHeight) para.paragraphSpacing = max(para.paragraphSpacing, paragraphSpacing) - + // The anchor character is given an explicit advance equal to the + // image width (often wider than the text container + para.lineBreakMode = .byClipping let collapsedPara = NSMutableParagraphStyle() collapsedPara.maximumLineHeight = 1 collapsedPara.paragraphSpacing = 0 From 9dae1b6a8030f9147819fe0f0883344747888606 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Thu, 21 May 2026 18:31:50 +0200 Subject: [PATCH 4/8] add vertikal scroll on tables --- .../Renderer/MarkdownTextLayoutFragment.swift | 18 ++ .../Renderer/WideTableOverlay.swift | 203 +++++++++++++++ .../Styling/MarkdownStyler+Tables.swift | 38 ++- .../Styling/MarkdownStyler.swift | 232 +++++++++++------- .../NativeTextViewCoordinator+Restyling.swift | 13 + .../NativeTextView+FrameAndOverscroll.swift | 7 + .../NativeTextView/NativeTextView.swift | 6 + .../TextView/NativeTextViewWrapper.swift | 2 + 8 files changed, 433 insertions(+), 86 deletions(-) create mode 100644 Sources/MarkdownEngine/Renderer/WideTableOverlay.swift diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index a2dac6d..1412f5a 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -21,6 +21,12 @@ extension NSAttributedString.Key { /// 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") + /// 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") } final class MarkdownTextLayoutFragment: NSTextLayoutFragment { @@ -30,6 +36,9 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { 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. @@ -318,6 +327,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 @@ -341,6 +354,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 diff --git a/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift b/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift new file mode 100644 index 0000000..4c9f9e6 --- /dev/null +++ b/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift @@ -0,0 +1,203 @@ +// +// WideTableOverlay.swift +// MarkdownEngine +// +// NSScrollView subview of NativeTextView hosting a wide-table image with +// native horizontal scrolling. Sidesteps TextKit 2's fragment surface +// cache that defeats in-fragment custom scrolling. +// + +import AppKit + +// MARK: - Overlay view + +final class WideTableOverlay: NSScrollView { + + /// Hash of table source; key for offset persistence + reconcile lookup. + let sourceID: Int + + /// Document index of the table anchor; click on image moves caret here. + var anchorTextLocation: Int + + /// Weak parent ref for offset persistence + caret forwarding. + weak var ownerTextView: NativeTextView? + + private let tableImageView: WideTableImageView + + init(sourceID: Int, image: NSImage, ownerTextView: NativeTextView, anchorLocation: Int) { + self.sourceID = sourceID + self.anchorTextLocation = anchorLocation + self.ownerTextView = ownerTextView + self.tableImageView = WideTableImageView(frame: CGRect(origin: .zero, size: image.size)) + tableImageView.image = image + tableImageView.imageScaling = .scaleNone + tableImageView.imageAlignment = .alignTopLeft + + super.init(frame: .zero) + + hasHorizontalScroller = true + hasVerticalScroller = false + autohidesScrollers = false + borderType = .noBorder + drawsBackground = false + scrollerStyle = .legacy + horizontalScrollElasticity = .allowed + verticalScrollElasticity = .none + usesPredominantAxisScrolling = true + horizontalScroller?.controlSize = .small + + documentView = tableImageView + tableImageView.ownerOverlay = self + + NotificationCenter.default.addObserver( + self, selector: #selector(scrollOffsetDidChange), + name: NSScrollView.didLiveScrollNotification, object: self + ) + NotificationCenter.default.addObserver( + self, selector: #selector(scrollOffsetDidChange), + name: NSScrollView.didEndLiveScrollNotification, object: self + ) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) not supported") } + + deinit { NotificationCenter.default.removeObserver(self) } + + /// Clamp vertical scroll to 0 on every layout — kills sub-pixel wobble. + override func tile() { + super.tile() + let origin = contentView.bounds.origin + if origin.y != 0 { + contentView.scroll(to: NSPoint(x: origin.x, y: 0)) + reflectScrolledClipView(contentView) + } + } + + /// Forward vertical-dominant scrolls to the outer document so the user + /// can scroll past the table even while hovering over it. + override func scrollWheel(with event: NSEvent) { + if abs(event.scrollingDeltaY) > abs(event.scrollingDeltaX) { + nextResponder?.scrollWheel(with: event) + return + } + super.scrollWheel(with: event) + } + + /// Swap the rendered image after a restyle regenerated it. + func updateImage(_ image: NSImage) { + if tableImageView.image !== image { + tableImageView.image = image + tableImageView.frame = CGRect(origin: .zero, size: image.size) + } + } + + var horizontalOffset: CGFloat { + get { contentView.bounds.origin.x } + set { + contentView.scroll(to: NSPoint(x: max(0, newValue), y: 0)) + reflectScrolledClipView(contentView) + } + } + + @objc private func scrollOffsetDidChange() { + ownerTextView?.tableHorizontalScrollOffsets[sourceID] = horizontalOffset + } +} + +// MARK: - Document view inside the overlay + +/// Forwards mouseDown to caret-into-table (so clicking switches to edit mode). +final class WideTableImageView: NSImageView { + + weak var ownerOverlay: WideTableOverlay? + + override func mouseDown(with event: NSEvent) { + guard let overlay = ownerOverlay, + let textView = overlay.ownerTextView else { + super.mouseDown(with: event) + return + } + let location = overlay.anchorTextLocation + let docLen = (textView.string as NSString).length + guard location >= 0, location <= docLen else { + super.mouseDown(with: event) + return + } + textView.window?.makeFirstResponder(textView) + textView.setSelectedRange(NSRange(location: location, length: 0)) + } +} + +// MARK: - NativeTextView reconcile extension + +extension NativeTextView { + + /// Walk storage; create / position / destroy overlays to match attrs. + func updateWideTableOverlays() { + guard let storage = textStorage, + let bridge = layoutBridge, + let container = bridge.firstTextContainer, + let tlm = textLayoutManager, + let tcs = tlm.textContentManager as? NSTextContentStorage else { + removeAllWideTableOverlays() + return + } + + let containerWidth = container.size.width + guard containerWidth.isFinite, containerWidth > 0 else { return } + + var seenSourceIDs: Set = [] + let fullRange = NSRange(location: 0, length: storage.length) + + storage.enumerateAttribute(.scrollableBlockSourceID, in: fullRange, options: []) { value, attrRange, _ in + guard let sourceID = value as? Int, + let image = storage.attribute(.latexImage, at: attrRange.location, effectiveRange: nil) as? NSImage else { return } + seenSourceIDs.insert(sourceID) + + // Force anchor layout so boundingRect returns a real frame. + if let start = tcs.location(tcs.documentRange.location, offsetBy: attrRange.location), + let end = tcs.location(start, offsetBy: attrRange.length), + let textRange = NSTextRange(location: start, end: end) { + tlm.ensureLayout(for: textRange) + } + + let anchorRect = bridge.boundingRect(forCharacterRange: attrRange, in: container) + guard !anchorRect.isEmpty else { return } + + let totalHeight = (storage.attribute(.scrollableBlockTotalHeight, at: attrRange.location, effectiveRange: nil) as? CGFloat) ?? image.size.height + let overlayFrame = NSRect( + x: textContainerOrigin.x + anchorRect.minX, + y: textContainerOrigin.y + anchorRect.minY, + width: containerWidth, + height: totalHeight + ) + + if let existing = wideTableOverlays[sourceID] { + if !existing.frame.equalTo(overlayFrame) { existing.frame = overlayFrame } + existing.updateImage(image) + existing.anchorTextLocation = attrRange.location + } else { + let overlay = WideTableOverlay( + sourceID: sourceID, image: image, + ownerTextView: self, anchorLocation: attrRange.location + ) + overlay.frame = overlayFrame + addSubview(overlay) + wideTableOverlays[sourceID] = overlay + let savedOffset = tableHorizontalScrollOffsets[sourceID] ?? 0 + if savedOffset > 0 { overlay.horizontalOffset = savedOffset } + } + } + + for (sourceID, overlay) in wideTableOverlays where !seenSourceIDs.contains(sourceID) { + overlay.removeFromSuperview() + wideTableOverlays.removeValue(forKey: sourceID) + } + } + + /// Drop all overlays synchronously (file switch path). + func removeAllWideTableOverlays() { + for (_, overlay) in wideTableOverlays { overlay.removeFromSuperview() } + wideTableOverlays.removeAll() + } +} diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index 19151ab..f1265ae 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -63,6 +63,16 @@ extension MarkdownStyler { latex: ctx.services.latex ) let imageBounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) + // Wide tables → scrollable mode (NSScrollView overlay); narrow → collapsed. + let containerWidth = effectiveContainerWidth(for: ctx) + let isWide = image.size.width > containerWidth + 0.5 + let mode: RenderedStandaloneBlockMode = isWide + ? .collapsedSourceScrollable( + markerTexts: [], + displayWidth: containerWidth, + sourceID: stableTableSourceID(for: source) + ) + : .collapsedSource(markerTexts: []) _ = appendRenderedStandaloneBlock( for: token, rawContent: source, @@ -71,7 +81,7 @@ extension MarkdownStyler { paragraphSpacingBefore: ctx.baseDefaultLineHeight * 0.5, paragraphSpacing: ctx.baseDefaultLineHeight * 0.5, alignment: .left, - mode: .collapsedSource(markerTexts: []), + mode: mode, ctx: ctx, attrs: &attrs ) @@ -413,4 +423,30 @@ extension MarkdownStyler { return true } } + + // MARK: - Scrollable table helpers + + /// Container width with fallback chain for "styler runs before layout" case. + static func effectiveContainerWidth(for ctx: StylingContext) -> CGFloat { + if let container = ctx.layoutBridge?.firstTextContainer { + let raw = container.size.width + if raw.isFinite, raw > 0, raw < 100_000 { return raw } + if let textView = container.textView { + let inset = textView.textContainerInset + let usable = textView.bounds.width - inset.width * 2 + if usable.isFinite, usable > 0 { return usable } + let frameUsable = textView.frame.width - inset.width * 2 + if frameUsable.isFinite, frameUsable > 0 { return frameUsable } + } + } + return 500 + } + + /// Stable hash of source for overlay lookup + offset persistence. + static func stableTableSourceID(for source: String) -> Int { + var hasher = Hasher() + hasher.combine("table-overlay-v1") + hasher.combine(source) + return hasher.finalize() + } } diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift index f7eb2f2..c73c401 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift @@ -195,6 +195,12 @@ extension MarkdownStyler { enum RenderedStandaloneBlockMode { case collapsedSource(markerTexts: [String]) case visibleSource(imageGap: CGFloat) + /// Wide-table mode: anchor reserves container width, line gains scroller strip, tagged by sourceID. + case collapsedSourceScrollable( + markerTexts: [String], + displayWidth: CGFloat, + sourceID: Int + ) } static func appendRenderedStandaloneBlock( @@ -218,91 +224,44 @@ extension MarkdownStyler { switch mode { case .collapsedSource(let markerTexts): - let neededHeight = max(para.minimumLineHeight, imageBounds.height, baseLineHeight) - para.minimumLineHeight = neededHeight - para.maximumLineHeight = max(para.maximumLineHeight, neededHeight) - para.paragraphSpacing = max(para.paragraphSpacing, paragraphSpacing) - // The anchor character is given an explicit advance equal to the - // image width (often wider than the text container - para.lineBreakMode = .byClipping - let collapsedPara = NSMutableParagraphStyle() - collapsedPara.maximumLineHeight = 1 - collapsedPara.paragraphSpacing = 0 - collapsedPara.paragraphSpacingBefore = 0 - - let leadingWhitespaceUnits = rawContent.utf16.prefix { codeUnit in - guard let scalar = UnicodeScalar(UInt32(codeUnit)) else { return false } - return CharacterSet.whitespacesAndNewlines.contains(scalar) - }.count - let contentEnd = NSMaxRange(token.contentRange) - let anchorLocation = min(token.contentRange.location + leadingWhitespaceUnits, contentEnd - 1) - - var paragraphAttributes: [StyledRange] = [] - ctx.nsText.enumerateSubstrings(in: paraRange, options: .byParagraphs) { _, _, enclosingRange, _ in - if NSLocationInRange(anchorLocation, enclosingRange) { - paragraphAttributes.append((enclosingRange, [.paragraphStyle: para])) - } else { - paragraphAttributes.append((enclosingRange, [.paragraphStyle: collapsedPara])) - } - } - attrs.append(contentsOf: paragraphAttributes) - - if leadingWhitespaceUnits > 0 { - let leadingRange = NSRange(location: token.contentRange.location, length: leadingWhitespaceUnits) - let leadingText = ctx.nsText.substring(with: leadingRange) - attrs.append((leadingRange, [ - .foregroundColor: NSColor.clear, - .font: ctx.latexMarkerFont, - .kern: -HeadingHelpers.textWidth(leadingText, font: ctx.latexMarkerFont) - ])) - } - - let anchorRange = NSRange(location: anchorLocation, length: 1) - let anchorChar = ctx.nsText.substring(with: anchorRange) - attrs.append((anchorRange, [ - .latexImage: image, - .latexBounds: NSValue(rect: imageBounds), - .latexIsBlock: true, - .foregroundColor: NSColor.clear, - .font: ctx.latexMarkerFont, - .kern: imageBounds.width - HeadingHelpers.textWidth(anchorChar, font: ctx.latexMarkerFont) - ])) - - let trailingStart = anchorLocation + 1 - let trailingLength = contentEnd - trailingStart - if trailingLength > 0 { - let trailingRange = NSRange(location: trailingStart, length: trailingLength) - let trailingText = ctx.nsText.substring(with: trailingRange) - attrs.append((trailingRange, [ - .foregroundColor: NSColor.clear, - .font: ctx.latexMarkerFont, - .kern: -HeadingHelpers.textWidth(trailingText, font: ctx.latexMarkerFont) - ])) - } - - for (index, markerRange) in token.markerRanges.enumerated() { - let markerText = markerTexts.indices.contains(index) - ? markerTexts[index] - : ctx.nsText.substring(with: markerRange) - attrs.append((markerRange, [ - .foregroundColor: NSColor.clear, - .font: ctx.latexMarkerFont, - .kern: -HeadingHelpers.textWidth(markerText, font: ctx.latexMarkerFont) - ])) - } - - // Hide whitespace between paragraph start and token start - // (e.g. a space before "![[") so it doesn't affect line layout. - let preTokenLength = token.range.location - paraRange.location - if preTokenLength > 0 { - let preTokenRange = NSRange(location: paraRange.location, length: preTokenLength) - let preTokenText = ctx.nsText.substring(with: preTokenRange) - attrs.append((preTokenRange, [ - .foregroundColor: NSColor.clear, - .font: ctx.latexMarkerFont, - .kern: -HeadingHelpers.textWidth(preTokenText, font: ctx.latexMarkerFont) - ])) - } + emitCollapsedAttrs( + token: token, + rawContent: rawContent, + image: image, + imageBounds: imageBounds, + paragraphSpacing: paragraphSpacing, + para: para, + paraRange: paraRange, + advanceWidth: imageBounds.width, + neededLineHeight: imageBounds.height, + extraAnchorAttrs: [:], + markerTexts: markerTexts, + ctx: ctx, + attrs: &attrs + ) + + case .collapsedSourceScrollable(let markerTexts, let displayWidth, let sourceID): + let scrollerStrip = MarkdownTextLayoutFragment.scrollableBlockScrollerStrip + let totalHeight = imageBounds.height + scrollerStrip + emitCollapsedAttrs( + token: token, + rawContent: rawContent, + image: image, + imageBounds: imageBounds, + paragraphSpacing: paragraphSpacing, + para: para, + paraRange: paraRange, + advanceWidth: displayWidth, + neededLineHeight: totalHeight, + extraAnchorAttrs: [ + .scrollableBlockNaturalWidth: imageBounds.width, + .scrollableBlockSourceID: sourceID, + .scrollableBlockTotalHeight: totalHeight + ], + markerTexts: markerTexts, + ctx: ctx, + attrs: &attrs + ) case .visibleSource(let imageGap): para.minimumLineHeight = max(para.minimumLineHeight, baseLineHeight) @@ -321,6 +280,109 @@ extension MarkdownStyler { return true } + + /// Shared body for collapsed-source modes; hides raw source, plants image on anchor. + private static func emitCollapsedAttrs( + token: MarkdownToken, + rawContent: String, + image: NSImage, + imageBounds: CGRect, + paragraphSpacing: CGFloat, + para: NSMutableParagraphStyle, + paraRange: NSRange, + advanceWidth: CGFloat, + neededLineHeight: CGFloat, + extraAnchorAttrs: [NSAttributedString.Key: Any], + markerTexts: [String], + ctx: StylingContext, + attrs: inout [StyledRange] + ) { + let baseLineHeight = layoutBridgeDefaultLineHeight(for: ctx.baseFont, using: ctx.layoutBridge) + let resolved = max(para.minimumLineHeight, neededLineHeight, baseLineHeight) + para.minimumLineHeight = resolved + para.maximumLineHeight = max(para.maximumLineHeight, resolved) + para.paragraphSpacing = max(para.paragraphSpacing, paragraphSpacing) + para.lineBreakMode = .byClipping + + let collapsedPara = NSMutableParagraphStyle() + collapsedPara.maximumLineHeight = 1 + collapsedPara.paragraphSpacing = 0 + collapsedPara.paragraphSpacingBefore = 0 + + let leadingWhitespaceUnits = rawContent.utf16.prefix { codeUnit in + guard let scalar = UnicodeScalar(UInt32(codeUnit)) else { return false } + return CharacterSet.whitespacesAndNewlines.contains(scalar) + }.count + let contentEnd = NSMaxRange(token.contentRange) + let anchorLocation = min(token.contentRange.location + leadingWhitespaceUnits, contentEnd - 1) + + var paragraphAttributes: [StyledRange] = [] + ctx.nsText.enumerateSubstrings(in: paraRange, options: .byParagraphs) { _, _, enclosingRange, _ in + if NSLocationInRange(anchorLocation, enclosingRange) { + paragraphAttributes.append((enclosingRange, [.paragraphStyle: para])) + } else { + paragraphAttributes.append((enclosingRange, [.paragraphStyle: collapsedPara])) + } + } + attrs.append(contentsOf: paragraphAttributes) + + if leadingWhitespaceUnits > 0 { + let leadingRange = NSRange(location: token.contentRange.location, length: leadingWhitespaceUnits) + let leadingText = ctx.nsText.substring(with: leadingRange) + attrs.append((leadingRange, [ + .foregroundColor: NSColor.clear, + .font: ctx.latexMarkerFont, + .kern: -HeadingHelpers.textWidth(leadingText, font: ctx.latexMarkerFont) + ])) + } + + let anchorRange = NSRange(location: anchorLocation, length: 1) + let anchorChar = ctx.nsText.substring(with: anchorRange) + var anchorAttrs: [NSAttributedString.Key: Any] = [ + .latexImage: image, + .latexBounds: NSValue(rect: imageBounds), + .latexIsBlock: true, + .foregroundColor: NSColor.clear, + .font: ctx.latexMarkerFont, + .kern: advanceWidth - HeadingHelpers.textWidth(anchorChar, font: ctx.latexMarkerFont) + ] + for (key, value) in extraAnchorAttrs { anchorAttrs[key] = value } + attrs.append((anchorRange, anchorAttrs)) + + let trailingStart = anchorLocation + 1 + let trailingLength = contentEnd - trailingStart + if trailingLength > 0 { + let trailingRange = NSRange(location: trailingStart, length: trailingLength) + let trailingText = ctx.nsText.substring(with: trailingRange) + attrs.append((trailingRange, [ + .foregroundColor: NSColor.clear, + .font: ctx.latexMarkerFont, + .kern: -HeadingHelpers.textWidth(trailingText, font: ctx.latexMarkerFont) + ])) + } + + for (index, markerRange) in token.markerRanges.enumerated() { + let markerText = markerTexts.indices.contains(index) + ? markerTexts[index] + : ctx.nsText.substring(with: markerRange) + attrs.append((markerRange, [ + .foregroundColor: NSColor.clear, + .font: ctx.latexMarkerFont, + .kern: -HeadingHelpers.textWidth(markerText, font: ctx.latexMarkerFont) + ])) + } + + let preTokenLength = token.range.location - paraRange.location + if preTokenLength > 0 { + let preTokenRange = NSRange(location: paraRange.location, length: preTokenLength) + let preTokenText = ctx.nsText.substring(with: preTokenRange) + attrs.append((preTokenRange, [ + .foregroundColor: NSColor.clear, + .font: ctx.latexMarkerFont, + .kern: -HeadingHelpers.textWidth(preTokenText, font: ctx.latexMarkerFont) + ])) + } + } } // MARK: - Whole-document & inline-only styling kept inline (small helpers) diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index 2977447..eed8736 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -87,6 +87,13 @@ extension NativeTextViewCoordinator { } tlm.ensureLayout(for: tlm.documentRange) } + + // Reconcile wide-table overlays after layout settles. + if let nativeTextView = textView as? NativeTextView { + DispatchQueue.main.async { [weak nativeTextView] in + nativeTextView?.updateWideTableOverlays() + } + } } func restyleTextView( @@ -115,6 +122,12 @@ extension NativeTextViewCoordinator { precomputedTokens: tokens, configuration: configuration ) + // Reconcile wide-table overlays after layout settles. + if let nativeTextView = textView as? NativeTextView { + DispatchQueue.main.async { [weak nativeTextView] in + nativeTextView?.updateWideTableOverlays() + } + } } func parsedDocument(for text: String) -> ParsedDocument { diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift index 74d600f..bace146 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift @@ -119,6 +119,13 @@ extension NativeTextView { } recalcOverscroll(for: scrollView, targetWidth: newSize.width, debugTag: "setFrameSize") + + // Reposition wide-table overlays when container width changes. + if widthChanged { + DispatchQueue.main.async { [weak self] in + self?.updateWideTableOverlays() + } + } } override func scrollRangeToVisible(_ range: NSRange) { diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift index 59ad8da..81f6d19 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift @@ -48,6 +48,12 @@ final class NativeTextView: NSTextView { // MARK: Drag-select state var dragStartMouseScreenLoc: NSPoint? + // MARK: Wide-table overlay state + /// Live NSScrollView per wide table; keyed by source-ID hash. + var wideTableOverlays: [Int: WideTableOverlay] = [:] + /// Persisted horizontal scroll offset per wide table; survives restyles. + var tableHorizontalScrollOffsets: [Int: CGFloat] = [:] + override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() // Forward appearance changes to the embedder-supplied syntax highlighter diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index b521596..abe8527 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -310,6 +310,8 @@ public struct NativeTextViewWrapper: NSViewRepresentable { context.coordinator.didInitialFormatting = false context.coordinator.didEnsureLayoutForCurrentDocument = false context.coordinator.resetImageEmbedState() + // Drop old document's wide-table overlays synchronously. + (textView as? NativeTextView)?.removeAllWideTableOverlays() // Reset scroll to top of content so the previous file's scrollY // doesn't leak into a (potentially shorter) new file. nsView.contentView.scroll(to: NSPoint(x: 0, y: -nsView.contentInsets.top)) From 6fc52b306aad50b6f59e615692f1ae39ab62cd04 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Fri, 22 May 2026 00:46:07 +0200 Subject: [PATCH 5/8] tables bug fixes --- .../Input/MarkdownListHandler.swift | 11 +++----- .../Renderer/WideTableOverlay.swift | 24 ++++++++++++----- .../Styling/MarkdownStyler+Tables.swift | 26 ++++++++++++++++--- .../NativeTextView+FrameAndOverscroll.swift | 9 +++++-- 4 files changed, 51 insertions(+), 19 deletions(-) diff --git a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift index f3be22c..6bc96c5 100644 --- a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift +++ b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift @@ -33,8 +33,9 @@ struct MarkdownLists { /// 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]?"# + pattern: #"^( {0,3})(>+(?:[ \t]+>+)*)"# ) static let dashNoSpaceRegex = try! NSRegularExpression(pattern: #"^\s*-(?!\s)"#) static let numberRegex = try! NSRegularExpression(pattern: #"^\s*(\d+)\.$"#) @@ -387,14 +388,10 @@ struct MarkdownLists { } } - // Skip list continuation in code blocks + // Skip list / blockquote continuation in code blocks. guard listsEnabled && !isInCodeBlock else { return true } - // Blockquote continuation: mirror the bullet-list behaviour. - // Pressing Enter on `> foo` adds a new `> ` line at the same - // nesting depth (`>>>` stays `>>>`); pressing Enter on an empty - // marker line strips the prefix so the user can exit the quote - // without backspacing through it. + // Blockquote continuation: `> foo` → `\n> `, `>>>` stays `>>>`, empty marker → exit. let quoteLine = nsText.substring(with: currentLineRange) if let quoteMatch = MarkdownLists.blockquoteRegex.firstMatch( in: quoteLine, diff --git a/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift b/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift index 4c9f9e6..938bef1 100644 --- a/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift +++ b/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift @@ -73,14 +73,16 @@ final class WideTableOverlay: NSScrollView { } } - /// Forward vertical-dominant scrolls to the outer document so the user - /// can scroll past the table even while hovering over it. + /// Forward everything except clearly-horizontal events to the outer + /// document scroll. Zero-delta phase/momentum lifecycle events count + /// as "not horizontal" so the outer scroll view still receives the + /// gesture-ended notifications it needs to stop cleanly. override func scrollWheel(with event: NSEvent) { - if abs(event.scrollingDeltaY) > abs(event.scrollingDeltaX) { + if abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) { + super.scrollWheel(with: event) + } else { nextResponder?.scrollWheel(with: event) - return } - super.scrollWheel(with: event) } /// Swap the rendered image after a restyle regenerated it. @@ -146,6 +148,9 @@ extension NativeTextView { let containerWidth = container.size.width guard containerWidth.isFinite, containerWidth > 0 else { return } + // Settle layout before measuring — stale fragments would yield wrong anchor Ys. + tlm.ensureLayout(for: tlm.documentRange) + var seenSourceIDs: Set = [] let fullRange = NSRange(location: 0, length: storage.length) @@ -154,7 +159,6 @@ extension NativeTextView { let image = storage.attribute(.latexImage, at: attrRange.location, effectiveRange: nil) as? NSImage else { return } seenSourceIDs.insert(sourceID) - // Force anchor layout so boundingRect returns a real frame. if let start = tcs.location(tcs.documentRange.location, offsetBy: attrRange.location), let end = tcs.location(start, offsetBy: attrRange.length), let textRange = NSTextRange(location: start, end: end) { @@ -173,7 +177,12 @@ extension NativeTextView { ) if let existing = wideTableOverlays[sourceID] { - if !existing.frame.equalTo(overlayFrame) { existing.frame = overlayFrame } + if !existing.frame.equalTo(overlayFrame) { + // Invalidate both old + new region so the vacated area redraws. + self.setNeedsDisplay(existing.frame) + existing.frame = overlayFrame + self.setNeedsDisplay(overlayFrame) + } existing.updateImage(image) existing.anchorTextLocation = attrRange.location } else { @@ -190,6 +199,7 @@ extension NativeTextView { } for (sourceID, overlay) in wideTableOverlays where !seenSourceIDs.contains(sourceID) { + self.setNeedsDisplay(overlay.frame) overlay.removeFromSuperview() wideTableOverlays.removeValue(forKey: sourceID) } diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index f1265ae..b24da7b 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -27,6 +27,8 @@ extension MarkdownStyler { static func styleTables(_ ctx: StylingContext) -> [StyledRange] { var attrs: [StyledRange] = [] + // Per-content occurrence counter so identical tables get distinct sourceIDs. + var occurrenceByContentHash: [Int: Int] = [:] for (idx, token) in ctx.tokens.enumerated() where token.kind == .table { // The tokenizer already drops table matches that overlap a // fenced code block, so we don't re-check that here. (The @@ -38,6 +40,11 @@ extension MarkdownStyler { let source = ctx.nsText.substring(with: token.range) guard let parsed = parseTableSource(source) else { continue } + // Advance occurrence index even for active tables so inactive duplicates stay stable. + let contentHash = stableTableContentHash(for: source) + let occurrenceIndex = occurrenceByContentHash[contentHash, default: 0] + occurrenceByContentHash[contentHash] = occurrenceIndex + 1 + let isActive = ctx.activeTokenIndices.contains(idx) if isActive { // Caret inside the table — show source so the user can @@ -66,11 +73,15 @@ extension MarkdownStyler { // Wide tables → scrollable mode (NSScrollView overlay); narrow → collapsed. let containerWidth = effectiveContainerWidth(for: ctx) let isWide = image.size.width > containerWidth + 0.5 + let computedSourceID = stableTableSourceID( + for: source, + occurrenceIndex: occurrenceIndex + ) let mode: RenderedStandaloneBlockMode = isWide ? .collapsedSourceScrollable( markerTexts: [], displayWidth: containerWidth, - sourceID: stableTableSourceID(for: source) + sourceID: computedSourceID ) : .collapsedSource(markerTexts: []) _ = appendRenderedStandaloneBlock( @@ -442,11 +453,20 @@ extension MarkdownStyler { return 500 } - /// Stable hash of source for overlay lookup + offset persistence. - static func stableTableSourceID(for source: String) -> Int { + /// Content-only hash; intentionally collides for identical tables — disambiguated by occurrence index. + static func stableTableContentHash(for source: String) -> Int { var hasher = Hasher() hasher.combine("table-overlay-v1") hasher.combine(source) return hasher.finalize() } + + /// Per-instance ID = (content, nth-occurrence); stable across re-styles so scroll offsets persist. + static func stableTableSourceID(for source: String, occurrenceIndex: Int) -> Int { + var hasher = Hasher() + hasher.combine("table-overlay-v2") + hasher.combine(source) + hasher.combine(occurrenceIndex) + return hasher.finalize() + } } diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift index bace146..f782a45 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift @@ -120,10 +120,15 @@ extension NativeTextView { recalcOverscroll(for: scrollView, targetWidth: newSize.width, debugTag: "setFrameSize") - // Reposition wide-table overlays when container width changes. + // Width change must trigger a full re-style — wide-table kern bakes in displayWidth. if widthChanged { DispatchQueue.main.async { [weak self] in - self?.updateWideTableOverlays() + guard let self = self else { return } + if let coord = self.delegate as? NativeTextViewCoordinator { + coord.rebuildTextStorageAndStyle(self, from: self.string, invalidateLayout: true) + } else { + self.updateWideTableOverlays() + } } } } From 2f9ecde880acf64a9fee1d647e844e7c58adfd2a Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Fri, 22 May 2026 00:56:34 +0200 Subject: [PATCH 6/8] perfomance --- .../Renderer/MarkdownTextLayoutFragment.swift | 2 ++ .../Renderer/WideTableOverlay.swift | 17 +++++++++--- .../Styling/MarkdownStyler.swift | 3 ++- .../NativeTextView+FrameAndOverscroll.swift | 27 ++++++++++++++----- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index 1412f5a..f4559de 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -27,6 +27,8 @@ extension NSAttributedString.Key { 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 { diff --git a/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift b/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift index 938bef1..eca17e0 100644 --- a/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift +++ b/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift @@ -148,12 +148,23 @@ extension NativeTextView { let containerWidth = container.size.width guard containerWidth.isFinite, containerWidth > 0 else { return } - // Settle layout before measuring — stale fragments would yield wrong anchor Ys. - tlm.ensureLayout(for: tlm.documentRange) - var seenSourceIDs: Set = [] let fullRange = NSRange(location: 0, length: storage.length) + // Cheap presence-check first: skip the full-document layout pass when + // the doc has no wide tables. enumerateAttribute stops on first hit. + var hasAnyWideTable = false + storage.enumerateAttribute(.scrollableBlockSourceID, in: fullRange, options: []) { value, _, stop in + if value is Int { hasAnyWideTable = true; stop.pointee = true } + } + guard hasAnyWideTable else { + removeAllWideTableOverlays() + return + } + + // Settle layout before measuring — stale fragments would yield wrong anchor Ys. + tlm.ensureLayout(for: tlm.documentRange) + storage.enumerateAttribute(.scrollableBlockSourceID, in: fullRange, options: []) { value, attrRange, _ in guard let sourceID = value as? Int, let image = storage.attribute(.latexImage, at: attrRange.location, effectiveRange: nil) as? NSImage else { return } diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift index c73c401..e7ee0c7 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift @@ -256,7 +256,8 @@ extension MarkdownStyler { extraAnchorAttrs: [ .scrollableBlockNaturalWidth: imageBounds.width, .scrollableBlockSourceID: sourceID, - .scrollableBlockTotalHeight: totalHeight + .scrollableBlockTotalHeight: totalHeight, + .scrollableBlockFullRange: NSValue(range: paraRange) ], markerTexts: markerTexts, ctx: ctx, diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift index f782a45..6b13566 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift @@ -120,19 +120,34 @@ extension NativeTextView { recalcOverscroll(for: scrollView, targetWidth: newSize.width, debugTag: "setFrameSize") - // Width change must trigger a full re-style — wide-table kern bakes in displayWidth. + // Width change → only wide-table paragraphs need restyling (their kern bakes in displayWidth). if widthChanged { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - if let coord = self.delegate as? NativeTextViewCoordinator { - coord.rebuildTextStorageAndStyle(self, from: self.string, invalidateLayout: true) - } else { - self.updateWideTableOverlays() - } + self.restyleWideTableParagraphsForWidthChange() + self.updateWideTableOverlays() } } } + /// Restyle exactly the wide-table paragraphs using ranges stamped on their + /// anchors at original styling time — avoids re-tokenizing the whole doc. + private func restyleWideTableParagraphsForWidthChange() { + guard let storage = textStorage, + let coord = delegate as? NativeTextViewCoordinator else { return } + var ranges: [NSRange] = [] + var seen: Set = [] + let fullRange = NSRange(location: 0, length: storage.length) + storage.enumerateAttribute(.scrollableBlockFullRange, in: fullRange, options: []) { value, _, _ in + guard let v = value as? NSValue else { return } + let r = v.rangeValue + let key = "\(r.location):\(r.length)" + if seen.insert(key).inserted { ranges.append(r) } + } + guard !ranges.isEmpty else { return } + coord.restyleParagraphs(ranges, in: self) + } + override func scrollRangeToVisible(_ range: NSRange) { if suppressAutoRevealOnce { suppressAutoRevealOnce = false From 34435b504dadc9bed113e64a58add1fe3599d2fe Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Fri, 22 May 2026 23:10:27 +0200 Subject: [PATCH 7/8] render bullets via drawing, keep raw Markdown in storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual port of xVanTuring's refactor from https://github.com/xVanTuring/swift-markdown-engine/commit/0ccb998 plus local adjustments: - Shift+Tab now outdents `-`/`*`/`+` markers and supports new format - Immediate render of `•` after typing space (no edge-reveal) - Rect-based hit-test for task-checkbox clicks - Reverted legacy-bullet indent detection (acceptable trade-off) Co-Authored-By: xVan Turing --- .../Input/MarkdownListHandler.swift | 132 ++---------------- .../Renderer/MarkdownTextLayoutFragment.swift | 49 +++++++ .../MarkdownStyler+BulletMarkers.swift | 72 ++++++++++ .../Styling/MarkdownStyler.swift | 1 + .../MarkdownEngine/TextView/ContextMenu.swift | 5 +- .../NativeTextViewCoordinator+Restyling.swift | 8 +- ...tiveTextViewCoordinator+TextDelegate.swift | 17 ++- .../NativeTextView+TaskCheckbox.swift | 29 ++-- .../TextView/NativeTextViewWrapper.swift | 3 +- .../MarkdownEngineDecouplingTests.swift | 64 ++++++--- 10 files changed, 216 insertions(+), 164 deletions(-) create mode 100644 Sources/MarkdownEngine/Styling/MarkdownStyler+BulletMarkers.swift diff --git a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift index 6bc96c5..c7d7be3 100644 --- a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift +++ b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift @@ -27,8 +27,9 @@ 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 @@ -38,19 +39,8 @@ struct MarkdownLists { 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*"#) - /// Matches an optionally-indented `- `, `* ` or `+ ` at the start of a - /// line — the three CommonMark bullet markers, with leading spaces - /// and/or tabs (nested items). Used by `normalizeBulletMarkers` to - /// convert pasted/loaded bullets into the engine's canonical - /// `• ` form. - static let pasteableDashBulletRegex = try! NSRegularExpression( - pattern: #"^([ \t]*)[-+*] "#, - options: [.anchorsMatchLines] - ) - static func indentLevel(from leadingWhitespace: String) -> Int { let tabCount = leadingWhitespace.filter { $0 == "\t" }.count let spaceCount = leadingWhitespace.filter { $0 == " " }.count @@ -78,47 +68,6 @@ struct MarkdownLists { return false } - // MARK: - Storage Normalization - - /// Rewrite standard-Markdown bullets (`- foo`, `* foo`, `+ foo`) to the - /// engine's canonical bullet form (`\t• foo`) so pasted or - /// programmatically loaded markdown renders with the same hanging indent - /// and bullet glyph as bullets the user types directly. The typed-input - /// path already rewrites `-` → `\t• ` on space-after-dash; this closes - /// the gap for every other ingestion path. Code blocks are left - /// untouched. - static func normalizeBulletMarkers(_ text: String) -> String { - guard !text.isEmpty else { return text } - let nsText = text as NSString - let fullRange = NSRange(location: 0, length: nsText.length) - let matches = pasteableDashBulletRegex.matches(in: text, options: [], range: fullRange) - guard !matches.isEmpty else { return text } - // Parse code-block tokens once so per-match code-block lookups stay O(tokens), - // not O(parse) — pasting a huge document with many dash lines would - // otherwise tokenize once per match. - let codeTokens = text.contains("`") - ? MarkdownTokenizer.parseTokens(in: text).filter { $0.kind == .codeBlock || $0.kind == .inlineCode } - : [] - let mutable = NSMutableString(string: text) - // Walk in reverse so untouched offsets stay valid as we mutate. - for match in matches.reversed() { - let lineStart = match.range.location - if !codeTokens.isEmpty, - MarkdownDetection.isInsideCodeBlock(location: lineStart, codeTokens: codeTokens) { - continue - } - // Map the source indent (spaces and/or tabs) to a nesting - // depth and rewrite the whole ` ` prefix to the - // canonical `• `. depth+1 keeps the existing - // "top level = one tab" convention paragraphAttributes expects. - let ws = nsText.substring(with: match.range(at: 1)) - let depth = indentLevel(from: ws) - let canonical = String(repeating: "\t", count: depth + 1) + "• " - mutable.replaceCharacters(in: match.range, with: canonical) - } - return mutable as String - } - // MARK: - Paragraph Attributes for List Styling static func paragraphAttributes( @@ -163,8 +112,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])) } @@ -177,7 +128,7 @@ 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]) { let bulletMatches = bulletListRegex.matches(in: text, options: [], range: fullRange) applyListMatches(bulletMatches) @@ -213,36 +164,6 @@ struct MarkdownLists { ? MarkdownDetection.isInsideCodeBlock(location: affectedCharRange.location, in: textView.string) : false - // BACKSPACE on an empty bullet prefix line ("\t+• " with no other - // content): undo the auto-conversion that turned a typed `-` + space - // into `\t• `. We collapse the whole prefix back to `-` so the user - // can keep deleting cleanly instead of stalling on the bullet glyph. - if listsEnabled, - replacementString.isEmpty, - affectedCharRange.length == 1, - !isInCodeBlock { - let nsText = textView.string as NSString - let safeLoc = min(affectedCharRange.location, nsText.length) - let lineRange = nsText.lineRange(for: NSRange(location: safeLoc, length: 0)) - // Strip trailing newline so the regex anchors against true end-of-line. - var contentLength = lineRange.length - if contentLength > 0, - nsText.character(at: lineRange.location + contentLength - 1) == 0x000A { - contentLength -= 1 - } - if contentLength > 0 { - let lineContentRange = NSRange(location: lineRange.location, length: contentLength) - let lineContent = nsText.substring(with: lineContentRange) - if lineContent.range(of: #"^\t+• $"#, options: .regularExpression) != nil, - affectedCharRange.location >= lineRange.location, - affectedCharRange.location < lineRange.location + contentLength { - MarkdownLists.performEdit(textView, replace: lineContentRange, with: "-") - textView.setSelectedRange(NSRange(location: lineRange.location + 1, length: 0)) - return false - } - } - } - if replacementString == ">" && affectedCharRange.length == 0 && !isInCodeBlock { let insertionLocation = affectedCharRange.location guard insertionLocation > 0 else { return true } @@ -324,34 +245,6 @@ 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: list continuation/outdent if replacementString == "\n" { let nsText = textView.string as NSString @@ -448,12 +341,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/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index f4559de..12788ba 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -21,6 +21,9 @@ extension NSAttributedString.Key { /// 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. @@ -82,6 +85,9 @@ 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) @@ -486,6 +492,49 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { } } + // MARK: - Bullet Markers + + /// Paint a `•` over every hidden bullet marker (`.bulletMarker`). The + /// glyph is drawn in the same font as the source so its baseline matches + /// the surrounding text, and centered within the original marker char's + /// advance so a `•` of a different width still sits where `-`/`*`/`+` was. + private func drawBulletMarkers(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() } + let nsContext = NSGraphicsContext(cgContext: context, flipped: true) + NSGraphicsContext.current = nsContext + + let theme = (textLayoutManager?.textContainer?.textView as? NativeTextView)? + .configuration.theme ?? .default + let storageString = ts.string as NSString + + ts.enumerateAttribute(.bulletMarker, in: range, options: []) { [weak self] value, attrRange, _ in + guard let self, (value as? Bool) == true else { return } + // Leave a selected marker alone so the highlighted raw char shows. + if selectionRanges.contains(where: { NSIntersectionRange($0, attrRange).length > 0 }) { 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 bulletAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: theme.bodyText] + let bullet = "•" as NSString + + let markerWidth = storageString.substring(with: attrRange).size(withAttributes: [.font: font]).width + let bulletWidth = bullet.size(withAttributes: bulletAttrs).width + let xOffset = max(0, (markerWidth - bulletWidth) / 2) + // Flipped context: text origin is its top edge, baseline sits one + // ascent below — so top = baseline − ascent aligns the glyph. + let topY = pos.baselineY - font.ascender + bullet.draw(at: CGPoint(x: pos.x + xOffset, y: topY), withAttributes: bulletAttrs) + } + } + // MARK: - Task List Checkboxes private func drawTaskCheckboxes(at point: CGPoint, in context: CGContext) { diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+BulletMarkers.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+BulletMarkers.swift new file mode 100644 index 0000000..8d0dd59 --- /dev/null +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+BulletMarkers.swift @@ -0,0 +1,72 @@ +// +// MarkdownStyler+BulletMarkers.swift +// MarkdownEngine +// +// Renders `-`/`*`/`+` markers as `•` via hide-and-draw; storage stays raw. +// + +import AppKit +import Foundation + +extension MarkdownStyler { + + /// Optionally-indented bullet marker at line start, NOT a task checkbox. + /// Trailing `[ \t]+` excludes thematic breaks (`---`) and emphasis (`*bold*`). + static let bulletListRegex: NSRegularExpression = try! NSRegularExpression( + pattern: #"^([ \t]*)([-*+])([ \t]+)(?!\[[ xX]\])"#, + options: [.anchorsMatchLines] + ) + + // MARK: Bullet Syntax Membership + + /// `` range on `location`'s line, or `nil` if the caret isn't strictly inside. + static func bulletSyntaxRange(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 = bulletListRegex.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) + if NSLocationInRange(location, syntaxRange) { + return syntaxRange + } + return nil + } + + // MARK: Bullet Markers + + static func styleBulletMarkers(_ ctx: StylingContext) -> [StyledRange] { + guard ctx.configuration.lists.helpersEnabled else { return [] } + var attrs: [StyledRange] = [] + let matches = bulletListRegex.matches(in: ctx.text, options: [], range: ctx.fullRange) + for match in matches { + let markerRange = match.range(at: 2) + let spacerRange = match.range(at: 3) + if markerRange.location == NSNotFound { continue } + if MarkdownDetection.isInsideCodeBlock(range: markerRange, codeTokens: ctx.codeTokens) { continue } + + // Reveal raw marker only while caret is strictly inside the syntax. + let syntaxStart = markerRange.location + let syntaxEnd = spacerRange.location + spacerRange.length + let isActiveSyntax = NSLocationInRange(ctx.caretLocation, NSRange(location: syntaxStart, length: syntaxEnd - syntaxStart)) + if isActiveSyntax { continue } + + // Hide marker char + tag for `•` overlay; trailing space stays visible. + attrs.append((markerRange, [ + .bulletMarker: true, + .foregroundColor: NSColor.clear + ])) + } + return attrs + } +} diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift index e7ee0c7..d6def69 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift @@ -173,6 +173,7 @@ enum MarkdownStyler { result += styleTables(ctx) result += styleIncompleteLinkBrackets(ctx) result += styleTaskCheckboxes(ctx) + result += styleBulletMarkers(ctx) result += shrinkInactiveMarkers(ctx) return result } diff --git a/Sources/MarkdownEngine/TextView/ContextMenu.swift b/Sources/MarkdownEngine/TextView/ContextMenu.swift index d2dcc4c..9865802 100644 --- a/Sources/MarkdownEngine/TextView/ContextMenu.swift +++ b/Sources/MarkdownEngine/TextView/ContextMenu.swift @@ -111,7 +111,8 @@ extension NativeTextViewWrapper.Coordinator { func isSelectionList(in nsText: NSString, range: NSRange) -> Bool { let lineRange = nsText.lineRange(for: range) let line = nsText.substring(with: lineRange) - return line.hasPrefix("\t• ") || line.hasPrefix("1. ") + return line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") + || line.hasPrefix("\t• ") || line.hasPrefix("1. ") } private func applyHeading(level: Int) { @@ -162,7 +163,7 @@ extension NativeTextViewWrapper.Coordinator { } @objc func didMarkdownUnorderedList(_ sender: Any?) { - applyList(prefix: "\t• ") + applyList(prefix: "- ") } @objc func didMarkdownOrderedList(_ sender: Any?) { diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index eed8736..8c022c2 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -19,12 +19,8 @@ extension NativeTextViewCoordinator { from text: String, invalidateLayout: Bool = false ) { - // Canonicalize standard-Markdown bullets (`- `/`* `/`+ `, incl. - // space-indented nested) to the engine's `\t• ` form for display. - // lastSyncedText stays the ORIGINAL text so the binding-change - // guard in updateNSView keeps working. - let normalizedInput = MarkdownLists.normalizeBulletMarkers(text) - let displayState = WikiLinkService.makeDisplayState(from: normalizedInput) + // Storage is raw Markdown; only wiki links transform on display. + let displayState = WikiLinkService.makeDisplayState(from: text) let displayText = displayState.display wikiLinkMetadata = displayState.metadata diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 99f3dd1..a86de73 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -238,9 +238,16 @@ extension NativeTextViewCoordinator { let currentHRLine = MarkdownStyler.hrLineRange(at: selLoc, in: tv.string) let hrLineChanged = prevHRLine?.location != currentHRLine?.location || prevHRLine?.length != currentHRLine?.length + // Bullet markers: caret in/out of `- ` syntax flips glyph ↔ raw. + let prevBulletSyntax = previousCaretLocation.flatMap { + MarkdownStyler.bulletSyntaxRange(at: $0, in: tv.string) + } + let currentBulletSyntax = MarkdownStyler.bulletSyntaxRange(at: selLoc, in: tv.string) + let bulletSyntaxChanged = prevBulletSyntax?.location != currentBulletSyntax?.location + || prevBulletSyntax?.length != currentBulletSyntax?.length if shouldSkipSelectionRestyle { // textDidChange performs the pending restyle for this edit cycle. - } else if tokensChanged || taskSyntaxChanged || hrLineChanged { + } else if tokensChanged || taskSyntaxChanged || hrLineChanged || bulletSyntaxChanged { restyleTextView(tv, paragraphCandidates: paragraphCandidates, tokens: tokens) } @@ -415,7 +422,7 @@ extension NativeTextViewCoordinator { let lineRange = nsText.lineRange(for: NSRange(location: caretLoc, length: 0)) let line = nsText.substring(with: lineRange) - let pattern = #"^([\t ]*)((\d+)\.|-|•)\s"# + let pattern = #"^([\t ]*)((\d+)\.|[-•*+])\s"# let regex = try? NSRegularExpression(pattern: pattern) if let regex = regex, let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: line.utf16.count)) { @@ -423,7 +430,11 @@ extension NativeTextViewCoordinator { let wsString = (line as NSString).substring(with: wsRangeLocal) let wsDocStart = lineRange.location + wsRangeLocal.location let depth = MarkdownLists.indentLevel(from: wsString) - if depth <= 1 { + // Legacy `\t• ` top-level depth=1 (synthetic tab); new format depth=0. + let markerString = (line as NSString).substring(with: match.range(at: 2)) + let isLegacyBulletGlyph = markerString.first == "•" + let minDepth = isLegacyBulletGlyph ? 1 : 0 + if depth <= minDepth { return true } diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift index 938e606..586aeb8 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift @@ -21,26 +21,31 @@ extension NativeTextView { x: localPoint.x - textContainerOrigin.x, y: localPoint.y - textContainerOrigin.y ) - var fraction: CGFloat = 0 - let index = bridge.characterIndex( - for: containerPoint, - in: textContainer, - fractionOfDistanceBetweenInsertionPoints: &fraction - ) - guard index != NSNotFound, index < storage.length else { return nil } - var effectiveRange = NSRange(location: 0, length: 0) - guard let isChecked = storage.attribute(.taskCheckbox, at: index, effectiveRange: &effectiveRange) as? Bool, - effectiveRange.length > 0 else { return nil } + // Rect-based hit-test — characterIndex(for:) mis-maps clicks on lines + // whose `[ ]` chars are hidden under the bullet/checkbox overlay. + let fullRange = NSRange(location: 0, length: storage.length) + var hitRange: NSRange? = nil + var hitIsChecked = false + storage.enumerateAttribute(.taskCheckbox, in: fullRange, options: []) { value, attrRange, stop in + guard let isChecked = value as? Bool else { return } + let rect = bridge.boundingRect(forCharacterRange: attrRange, in: textContainer) + if rect.contains(containerPoint) { + hitRange = attrRange + hitIsChecked = isChecked + stop.pointee = true + } + } + guard let effectiveRange = hitRange else { return nil } let nsText = storage.string as NSString let checkboxText = nsText.substring(with: effectiveRange) guard checkboxText.range(of: #"\[[ xX]\]"#, options: .regularExpression) != nil else { return nil } - let replacement = isChecked ? "[ ]" : "[x]" + let replacement = hitIsChecked ? "[ ]" : "[x]" if shouldChangeText(in: effectiveRange, replacementString: replacement) { storage.replaceCharacters(in: effectiveRange, with: replacement) - storage.addAttribute(.taskCheckbox, value: !isChecked, range: effectiveRange) + storage.addAttribute(.taskCheckbox, value: !hitIsChecked, range: effectiveRange) storage.addAttribute(.foregroundColor, value: NSColor.clear, range: effectiveRange) didChangeText() bridge.invalidateDisplay(forCharacterRange: effectiveRange) diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index abe8527..65fedab 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -148,8 +148,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { textView.isEditable = isEditable textView.isSelectable = isEditable textView.isRichText = true - let normalizedInput = MarkdownLists.normalizeBulletMarkers(text) - let initialState = WikiLinkService.makeDisplayState(from: normalizedInput) + let initialState = WikiLinkService.makeDisplayState(from: text) textView.string = initialState.display textView.delegate = context.coordinator textView.isVerticallyResizable = true diff --git a/Tests/MarkdownEngineTests/MarkdownEngineDecouplingTests.swift b/Tests/MarkdownEngineTests/MarkdownEngineDecouplingTests.swift index 373824d..af7107a 100644 --- a/Tests/MarkdownEngineTests/MarkdownEngineDecouplingTests.swift +++ b/Tests/MarkdownEngineTests/MarkdownEngineDecouplingTests.swift @@ -104,36 +104,58 @@ struct MarkdownEngineDecouplingTests { #expect(bus.findClearHighlights == nil) } - // MARK: Bullet normalization + // MARK: Bullet markers render via styling, never by mutating storage - @Test func dashBulletNormalizesToCanonicalBullet() { - let input = "- 你好\nplain line\n- second" - let output = MarkdownLists.normalizeBulletMarkers(input) - #expect(output == "\t• 你好\nplain line\n\t• second") + /// Locations whose styled attributes carry `.bulletMarker` (i.e. the raw + /// marker char is hidden and a `•` will be drawn there). + private func bulletMarkerLocations(_ text: String, caret: Int) -> Set { + let ranges = MarkdownStyler.styleAttributes( + text: text, + fontName: NSFont.systemFont(ofSize: 14).fontName, + fontSize: 14, + caretLocation: caret, + activeTokenIndices: [], + configuration: .default + ) + var locations: Set = [] + for (range, attrs) in ranges where attrs[.bulletMarker] != nil { + locations.insert(range.location) + } + return locations + } + + @Test func dashBulletMarkerIsHiddenAndTagged() { + // Caret off the marker line → the `-` at index 0 is tagged for a `•`. + let text = "- 你好" + #expect(bulletMarkerLocations(text, caret: (text as NSString).length).contains(0)) + } + + @Test func starAndPlusMarkersAreAlsoTagged() { + // `* a\n+ b` — markers at index 0 and 4 both render as bullets. + let text = "* a\n+ b" + let locations = bulletMarkerLocations(text, caret: (text as NSString).length) + #expect(locations.contains(0)) + #expect(locations.contains(4)) } - @Test func tabIndentedDashBulletKeepsItsDepth() { - let input = "- top\n\t- nested\n\t\t- deeper" - let output = MarkdownLists.normalizeBulletMarkers(input) - #expect(output == "\t• top\n\t\t• nested\n\t\t\t• deeper") + @Test func caretInsideMarkerSyntaxRevealsRawMarker() { + // Caret between `-` and its space → marker revealed, not tagged. + #expect(!bulletMarkerLocations("- item", caret: 1).contains(0)) } - @Test func horizontalRuleIsNotNormalizedAsBullet() { - let input = "before\n---\nafter" - let output = MarkdownLists.normalizeBulletMarkers(input) - #expect(output == input) + @Test func taskCheckboxLineIsNotBulletTagged() { + // `- [ ]` is owned by the checkbox styler, not the bullet styler. + #expect(!bulletMarkerLocations("- [ ] todo", caret: 99).contains(0)) } - @Test func dashInsideFencedCodeBlockIsLeftAlone() { - let input = "```\n- not a bullet\n```\n- real bullet" - let output = MarkdownLists.normalizeBulletMarkers(input) - #expect(output == "```\n- not a bullet\n```\n\t• real bullet") + @Test func bulletInsideFencedCodeBlockIsNotTagged() { + // The `-` at index 4 is inside ``` … ``` and stays plain text. + #expect(!bulletMarkerLocations("```\n- x\n```", caret: 99).contains(4)) } - @Test func textWithoutDashBulletsIsReturnedUntouched() { - let input = "no bullets here\nstill nothing" - let output = MarkdownLists.normalizeBulletMarkers(input) - #expect(output == input) + @Test func thematicBreakIsNotBulletTagged() { + // `---` has no space after the first `-`, so it is never a bullet. + #expect(bulletMarkerLocations("before\n---\nafter", caret: 99).isEmpty) } // MARK: Styler runs end-to-end with defaults From 29fbe573a9b860a367cdaa0dcd6e11cae2e7fb83 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Fri, 22 May 2026 23:52:21 +0200 Subject: [PATCH 8/8] normalize space-indented nested bullet Co-Authored-By: xVan Turing --- Sources/MarkdownEngine/Input/MarkdownListHandler.swift | 6 ++---- Sources/MarkdownEngine/Styling/TextStylingService.swift | 4 ++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift index c7d7be3..94e8d86 100644 --- a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift +++ b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift @@ -85,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 { @@ -98,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 diff --git a/Sources/MarkdownEngine/Styling/TextStylingService.swift b/Sources/MarkdownEngine/Styling/TextStylingService.swift index 49e51de..0676466 100644 --- a/Sources/MarkdownEngine/Styling/TextStylingService.swift +++ b/Sources/MarkdownEngine/Styling/TextStylingService.swift @@ -38,6 +38,10 @@ struct TextStylingService { paragraph.paragraphSpacing = baseParagraphSpacing paragraph.paragraphSpacingBefore = 0 paragraph.lineBreakMode = .byWordWrapping + // 24 explicit tab stops at indentPerLevel intervals, then natural wrap. + let perLevel = configuration.lists.indentPerLevel + paragraph.tabStops = (1...24).map { NSTextTab(textAlignment: .left, location: CGFloat($0) * perLevel) } + paragraph.defaultTabInterval = 0 return (baseFont, paragraph) }