From 56fa23ff1acaa10f53591016b2c7cad3b469f117 Mon Sep 17 00:00:00 2001 From: Christine Tham Date: Sat, 11 Jul 2026 18:04:58 +1000 Subject: [PATCH 1/3] Add callouts, %%comments%%, and front-matter hiding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three editor-styling passes (all keyed off the styling context, which now carries the caret location): - Callouts: a `> [!type]` blockquote gets a tinted band + accent bar (new `.calloutTint` attribute drawn by MarkdownTextLayoutFragment), a bold accent-colored title, and hidden `[!` `]` punctuation. Type→color mapping covers the common Obsidian set. - Comments: `%%…%%` (inline or multi-line) are dimmed; code/LaTeX spans are skipped. - Front matter: a leading `---`…`---` block collapses to nothing (its fence thematic-break rules suppressed), revealing the raw YAML only when the caret is inside it. Co-Authored-By: Claude Opus 4.8 --- .../Renderer/MarkdownTextLayoutFragment.swift | 43 +++++++ .../Styling/MarkdownStyler+Callouts.swift | 107 ++++++++++++++++++ .../Styling/MarkdownStyler+Comments.swift | 54 +++++++++ .../Styling/MarkdownStyler+FrontMatter.swift | 85 ++++++++++++++ .../Styling/MarkdownStyler.swift | 5 + 5 files changed, 294 insertions(+) create mode 100644 Sources/MarkdownEngine/Styling/MarkdownStyler+Callouts.swift create mode 100644 Sources/MarkdownEngine/Styling/MarkdownStyler+Comments.swift create mode 100644 Sources/MarkdownEngine/Styling/MarkdownStyler+FrontMatter.swift diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index 12788ba7..ac5d7e86 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -32,6 +32,9 @@ extension NSAttributedString.Key { 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") + /// NSColor accent of a callout line; the fragment fills a tinted full-width + /// band behind the line and paints a solid bar of this color in the gutter. + static let calloutTint = NSAttributedString.Key("CalloutTint") } final class MarkdownTextLayoutFragment: NSTextLayoutFragment { @@ -76,6 +79,9 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { // 1. Code-block backgrounds (behind text) drawCodeBlockBackground(at: point, in: context) + // 1b. Callout tinted bands (behind text) + drawCalloutBackground(at: point, in: context) + // 2. LaTeX images (behind text — hidden markers are invisible anyway) drawLatexImages(at: point, in: context) @@ -492,6 +498,43 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { } } + // MARK: - Callout Background + + /// Fill a tinted full-width band behind every line carrying `.calloutTint` + /// and paint a solid accent bar in the left gutter, so an Obsidian-style + /// `> [!type]` callout reads as a colored box. + private func drawCalloutBackground(at point: CGPoint, in context: CGContext) { + guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return } + var anyTint = false + ts.enumerateAttribute(.calloutTint, in: range, options: []) { value, _, stop in + if value is NSColor { anyTint = true; stop.pointee = true } + } + guard anyTint else { return } + + let containerWidth = textLayoutManager?.textContainer?.size.width ?? layoutFragmentFrame.width + let barWidth = Self.blockquoteBarWidth + + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + let nsContext = NSGraphicsContext(cgContext: context, flipped: true) + NSGraphicsContext.current = nsContext + + let fragLocation = range.location + let leftEdge = point.x - layoutFragmentFrame.origin.x + for lineFragment in textLineFragments { + let lr = lineFragment.characterRange + let docStart = fragLocation + lr.location + guard docStart < ts.length else { continue } + guard let tint = ts.attribute(.calloutTint, at: docStart, effectiveRange: nil) as? NSColor else { continue } + let tb = lineFragment.typographicBounds + let bandRect = CGRect(x: leftEdge, y: point.y + tb.origin.y, width: containerWidth, height: tb.height) + tint.withAlphaComponent(0.12).setFill() + NSBezierPath(rect: bandRect).fill() + tint.withAlphaComponent(0.9).setFill() + NSBezierPath(rect: CGRect(x: leftEdge, y: bandRect.minY, width: barWidth, height: tb.height)).fill() + } + } + // MARK: - Bullet Markers /// Paint a `•` over every hidden bullet marker (`.bulletMarker`). The diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Callouts.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Callouts.swift new file mode 100644 index 00000000..b99bec90 --- /dev/null +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Callouts.swift @@ -0,0 +1,107 @@ +// +// MarkdownStyler+Callouts.swift +// MarkdownEngine +// +// Styles Obsidian-style callouts: a blockquote whose first line begins with +// `[!type]`. The block gets a tinted band + accent bar (drawn by +// MarkdownTextLayoutFragment via `.calloutTint`), the title line is set bold +// in the accent color, and the `[!` `]` punctuation is hidden. +// + +import AppKit +import Foundation + +extension MarkdownStyler { + + private static let calloutHeaderRegex = try! NSRegularExpression( + pattern: #"^(\s*)\[!([A-Za-z][\w-]*)\]"# + ) + + static func styleCallouts(_ ctx: StylingContext) -> [StyledRange] { + var attrs: [StyledRange] = [] + for group in blockquoteGroups(in: ctx) { + guard let first = group.first else { continue } + let content = ctx.nsText.substring(with: first.contentRange) + guard let match = calloutHeaderRegex.firstMatch( + in: content, range: NSRange(content.startIndex..., in: content) + ) else { continue } + + let wsLen = match.range(at: 1).length + let type = (content as NSString).substring(with: match.range(at: 2)).lowercased() + let tint = calloutColor(for: type) + + // Tinted band + accent bar per line; suppress the grey quote bar. + for line in group { + attrs.append((line.range, [.calloutTint: tint])) + attrs.append((line.range, [.blockquoteLevel: 0])) + } + + // Title line: bold + accent, and hide the `[!` … `]` punctuation. + let contentLoc = first.contentRange.location + let headerContentRange = NSRange(location: contentLoc + wsLen, + length: first.contentRange.length - wsLen) + let headerFont = boldFont(ctx.baseFont) + attrs.append((headerContentRange, [.foregroundColor: tint, .font: headerFont])) + + hide(NSRange(location: contentLoc + wsLen, length: 2), ctx: ctx, into: &attrs) // "[!" + let typeRange = match.range(at: 2) + let closeBracket = NSRange(location: contentLoc + typeRange.location + typeRange.length, length: 1) // "]" + hide(closeBracket, ctx: ctx, into: &attrs) + } + return attrs + } + + private static func boldFont(_ font: NSFont) -> NSFont { + let merged = font.fontDescriptor.symbolicTraits.union(.bold) + return NSFont(descriptor: font.fontDescriptor.withSymbolicTraits(merged), size: font.pointSize) ?? font + } + + /// Kern a short run to zero width and clear it, reusing the marker-hiding trick. + private static func hide(_ range: NSRange, ctx: StylingContext, into attrs: inout [StyledRange]) { + guard range.location >= 0, NSMaxRange(range) <= ctx.nsText.length else { return } + let text = ctx.nsText.substring(with: range) + attrs.append((range, [ + .foregroundColor: NSColor.clear, + .font: ctx.latexMarkerFont, + .kern: -HeadingHelpers.textWidth(text, font: ctx.latexMarkerFont) + ])) + } + + /// Consecutive `.blockquote` line tokens grouped into contiguous blocks. + private static func blockquoteGroups(in ctx: StylingContext) -> [[MarkdownToken]] { + var groups: [[MarkdownToken]] = [] + var current: [MarkdownToken] = [] + var lastEnd = -1 + for token in ctx.tokens where token.kind == .blockquote { + let contiguous = !current.isEmpty && isSingleLineBreak(between: lastEnd, and: token.range.location, in: ctx.nsText) + if contiguous { + current.append(token) + } else { + if !current.isEmpty { groups.append(current) } + current = [token] + } + lastEnd = NSMaxRange(token.range) + } + if !current.isEmpty { groups.append(current) } + return groups + } + + /// True when the gap between two blockquote lines is exactly one line break. + private static func isSingleLineBreak(between end: Int, and start: Int, in text: NSString) -> Bool { + guard end >= 0, start >= end, start <= text.length else { return false } + let gap = text.substring(with: NSRange(location: end, length: start - end)) + return gap == "\n" || gap == "\r\n" || gap == "\r" + } + + private static func calloutColor(for type: String) -> NSColor { + switch type { + case "tip", "hint", "success", "check", "done": return .systemGreen + case "warning", "caution", "attention": return .systemOrange + case "failure", "fail", "danger", "error", "bug", "missing": return .systemRed + case "example": return .systemPurple + case "quote", "cite": return .systemGray + case "question", "help", "faq": return .systemTeal + default: return .systemBlue // note, info, todo, abstract, … + } + } +} diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Comments.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Comments.swift new file mode 100644 index 00000000..c8185dc6 --- /dev/null +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Comments.swift @@ -0,0 +1,54 @@ +// +// MarkdownStyler+Comments.swift +// MarkdownEngine +// +// Dims Obsidian-style comments `%%…%%` (inline or spanning lines) so they read +// as hidden annotations. Comments inside code or LaTeX are left alone. +// + +import AppKit +import Foundation + +extension MarkdownStyler { + + static func styleComments(_ ctx: StylingContext) -> [StyledRange] { + let text = ctx.nsText + guard text.length >= 4 else { return [] } + + // Ranges where a `%%` must not start a comment: fenced/inline code and + // LaTeX (whose source `%` is a comment char and is rendered as an image). + let skipRanges: [NSRange] = ctx.tokens + .filter { $0.kind == .codeBlock || $0.kind == .inlineCode + || $0.kind == .inlineLatex || $0.kind == .blockLatex } + .map(\.range) + + var attrs: [StyledRange] = [] + let dim = ctx.configuration.theme.disabledText + var i = 0 + while i < text.length - 1 { + guard text.character(at: i) == 0x25, text.character(at: i + 1) == 0x25 else { + i += 1 + continue + } + // Find the closing `%%`. + var j = i + 2 + var closeAt = -1 + while j < text.length - 1 { + if text.character(at: j) == 0x25, text.character(at: j + 1) == 0x25 { + closeAt = j + break + } + j += 1 + } + guard closeAt >= 0 else { break } + + let range = NSRange(location: i, length: closeAt + 2 - i) + let overlapsSkip = skipRanges.contains { NSIntersectionRange($0, range).length > 0 } + if !overlapsSkip { + attrs.append((range, [.foregroundColor: dim, .spellingState: 0])) + } + i = closeAt + 2 + } + return attrs + } +} diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+FrontMatter.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+FrontMatter.swift new file mode 100644 index 00000000..57015984 --- /dev/null +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+FrontMatter.swift @@ -0,0 +1,85 @@ +// +// MarkdownStyler+FrontMatter.swift +// MarkdownEngine +// +// Hides a leading YAML front-matter block (`---` … `---`) in the editor. +// Hosts typically surface the parsed keys/values in their own UI, so the raw +// block is collapsed to nothing — unless the caret is inside it, in which case +// the raw YAML is revealed for editing. Without this the fences would also +// render as thematic-break rules, which this pass suppresses. +// + +import AppKit +import Foundation + +extension MarkdownStyler { + + static func styleFrontMatter(_ ctx: StylingContext) -> [StyledRange] { + guard let fm = frontMatterRange(in: ctx.nsText) else { return [] } + + var attrs: [StyledRange] = [] + // The opening/closing `---` lines are parsed as thematic breaks; never + // draw those rules for front-matter fences (editing or collapsed). + for fenceLine in fm.fenceLineRanges { + attrs.append((fenceLine, [.thematicBreak: false])) + } + + let editing = NSLocationInRange(ctx.caretLocation, fm.range) + || ctx.caretLocation == NSMaxRange(fm.range) + if editing { + // Reveal the raw YAML, muting the `---` fences so they read as syntax. + for fenceLine in fm.fenceLineRanges { + attrs.append((fenceLine, [.foregroundColor: ctx.configuration.theme.mutedText])) + } + return attrs + } + + // Collapse every front-matter line to a 1pt, invisible sliver. + let collapsed = NSMutableParagraphStyle() + collapsed.minimumLineHeight = 1 + collapsed.maximumLineHeight = 1 + collapsed.lineSpacing = 0 + collapsed.paragraphSpacing = 0 + collapsed.paragraphSpacingBefore = 0 + ctx.nsText.enumerateSubstrings(in: fm.range, options: .byParagraphs) { _, _, enclosing, _ in + attrs.append((enclosing, [.paragraphStyle: collapsed])) + } + attrs.append((fm.range, [ + .foregroundColor: NSColor.clear, + .font: ctx.latexMarkerFont, + .spellingState: 0 + ])) + return attrs + } + + struct FrontMatterRange { + let range: NSRange + /// The opening and closing `---` line ranges. + let fenceLineRanges: [NSRange] + } + + /// The leading `---` … `---` YAML front-matter block, if the document opens + /// with one. Requires `---` on the very first line and a later `---` line. + static func frontMatterRange(in text: NSString) -> FrontMatterRange? { + guard text.length >= 3 else { return nil } + let firstLine = text.lineRange(for: NSRange(location: 0, length: 0)) + guard isFenceLine(text.substring(with: firstLine)) else { return nil } + + var lineStart = NSMaxRange(firstLine) + while lineStart < text.length { + let line = text.lineRange(for: NSRange(location: lineStart, length: 0)) + if isFenceLine(text.substring(with: line)) { + let range = NSRange(location: 0, length: NSMaxRange(line)) + return FrontMatterRange(range: range, fenceLineRanges: [firstLine, line]) + } + let next = NSMaxRange(line) + if next <= lineStart { break } + lineStart = next + } + return nil + } + + private static func isFenceLine(_ line: String) -> Bool { + line.trimmingCharacters(in: .whitespacesAndNewlines) == "---" + } +} diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift index 9ac29299..8e01386b 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift @@ -23,6 +23,7 @@ extension MarkdownStyler { let tokens: [MarkdownToken] let codeTokens: [MarkdownToken] let activeTokenIndices: Set + let caretLocation: Int let baseFont: NSFont let layoutBridge: LayoutBridge? let baseDefaultLineHeight: CGFloat @@ -68,6 +69,7 @@ enum MarkdownStyler { tokens: tokens, codeTokens: codeTokens, activeTokenIndices: activeTokenIndices, + caretLocation: caretLocation, baseFont: baseFont, layoutBridge: layoutBridge, baseDefaultLineHeight: baseDefaultLineHeight, @@ -88,6 +90,9 @@ enum MarkdownStyler { // NSImage rendering reuses the existing, proven machinery. result += styleBlockLatex(ctx) result += styleInlineLatex(ctx) + result += styleFrontMatter(ctx) + result += styleComments(ctx) + result += styleCallouts(ctx) result += styleImageEmbeds(ctx) result += styleImageLinks(ctx) result += styleTables(ctx) From ad1e64c58ac3bce63ce5da3f9d0cc414ce8b474d Mon Sep 17 00:00:00 2001 From: Christine Tham Date: Sat, 11 Jul 2026 18:35:33 +1000 Subject: [PATCH 2/3] Callouts: header icons and collapsible folds Each callout header now paints an SF Symbol in the gutter (type-specific, in the accent color). Callouts written `[!type]-` / `[!type]+` are collapsible: `-` renders collapsed (body hidden, chevron.right), `+` expanded (chevron.down); clicking the header's gutter chevron toggles the marker in the source (mirrors the task-checkbox click handling) and restyles. The caret inside a collapsed callout reveals its body for editing. Co-Authored-By: Claude Opus 4.8 --- .../Renderer/MarkdownTextLayoutFragment.swift | 31 ++++++++ .../Styling/MarkdownStyler+Callouts.swift | 73 +++++++++++++++++-- .../NativeTextView+CalloutFold.swift | 62 ++++++++++++++++ .../NativeTextView+DragSelectBoost.swift | 1 + 4 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CalloutFold.swift diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index ac5d7e86..c3465ebf 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -35,6 +35,12 @@ extension NSAttributedString.Key { /// NSColor accent of a callout line; the fragment fills a tinted full-width /// band behind the line and paints a solid bar of this color in the gutter. static let calloutTint = NSAttributedString.Key("CalloutTint") + /// String SF Symbol name for a callout's header line; the fragment paints it + /// (in the accent color) in the gutter beside the title. + static let calloutIcon = NSAttributedString.Key("CalloutIcon") + /// Bool on a collapsible callout's header line (`true` = currently collapsed). + /// Makes the header's gutter clickable to toggle the fold. + static let calloutFold = NSAttributedString.Key("CalloutFold") } final class MarkdownTextLayoutFragment: NSTextLayoutFragment { @@ -532,7 +538,32 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { NSBezierPath(rect: bandRect).fill() tint.withAlphaComponent(0.9).setFill() NSBezierPath(rect: CGRect(x: leftEdge, y: bandRect.minY, width: barWidth, height: tb.height)).fill() + + // Header line: paint the SF Symbol in the gutter beside the title. + if let symbol = ts.attribute(.calloutIcon, at: docStart, effectiveRange: nil) as? String, + let icon = calloutIconImage(symbol, tint: tint) { + let side: CGFloat = 14 + let iconRect = CGRect( + x: leftEdge + barWidth + 4, + y: bandRect.minY + (tb.height - side) / 2, + width: side, height: side + ) + icon.draw(in: iconRect) + } + } + } + + private func calloutIconImage(_ symbol: String, tint: NSColor) -> NSImage? { + let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold) + guard let base = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)? + .withSymbolConfiguration(config) else { return nil } + let tinted = NSImage(size: base.size, flipped: false) { rect in + base.draw(in: rect) + tint.set() + rect.fill(using: .sourceAtop) + return true } + return tinted } // MARK: - Bullet Markers diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Callouts.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Callouts.swift index b99bec90..07077126 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Callouts.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Callouts.swift @@ -14,7 +14,7 @@ import Foundation extension MarkdownStyler { private static let calloutHeaderRegex = try! NSRegularExpression( - pattern: #"^(\s*)\[!([A-Za-z][\w-]*)\]"# + pattern: #"^(\s*)\[!([A-Za-z][\w-]*)\]([+-]?)"# ) static func styleCallouts(_ ctx: StylingContext) -> [StyledRange] { @@ -30,13 +30,37 @@ extension MarkdownStyler { let type = (content as NSString).substring(with: match.range(at: 2)).lowercased() let tint = calloutColor(for: type) + // Fold marker after `]`: `-` = collapsible & collapsed, `+` = collapsible + // & expanded, none = not collapsible. + let foldMarker = match.range(at: 3).length > 0 + ? (content as NSString).substring(with: match.range(at: 3)) : "" + let foldable = foldMarker == "-" || foldMarker == "+" + let calloutRange = NSRange(location: first.range.location, + length: NSMaxRange(group.last!.range) - first.range.location) + let caretInside = NSLocationInRange(ctx.caretLocation, calloutRange) + || ctx.caretLocation == NSMaxRange(calloutRange) + let collapsed = foldMarker == "-" && !caretInside + // Tinted band + accent bar per line; suppress the grey quote bar. - for line in group { - attrs.append((line.range, [.calloutTint: tint])) - attrs.append((line.range, [.blockquoteLevel: 0])) + // When collapsed, only the header line keeps the band; body lines hide. + for (i, line) in group.enumerated() { + if collapsed && i > 0 { + hideLine(line.range, ctx: ctx, into: &attrs) + } else { + attrs.append((line.range, [.calloutTint: tint])) + attrs.append((line.range, [.blockquoteLevel: 0])) + } + } + // Header line carries the SF Symbol the fragment paints in the gutter — + // a chevron for collapsible callouts, else the type icon. A `.calloutFold` + // flag makes the header's gutter clickable to toggle. + let icon = foldable ? (collapsed ? "chevron.right" : "chevron.down") : calloutIcon(for: type) + attrs.append((NSRange(location: first.range.location, length: 1), [.calloutIcon: icon])) + if foldable { + attrs.append((first.range, [.calloutFold: collapsed])) } - // Title line: bold + accent, and hide the `[!` … `]` punctuation. + // Title line: bold + accent, and hide the `[!` … `]±` punctuation. let contentLoc = first.contentRange.location let headerContentRange = NSRange(location: contentLoc + wsLen, length: first.contentRange.length - wsLen) @@ -45,12 +69,31 @@ extension MarkdownStyler { hide(NSRange(location: contentLoc + wsLen, length: 2), ctx: ctx, into: &attrs) // "[!" let typeRange = match.range(at: 2) - let closeBracket = NSRange(location: contentLoc + typeRange.location + typeRange.length, length: 1) // "]" + // Hide `]` plus any fold marker (`+`/`-`). + let closeLen = 1 + match.range(at: 3).length + let closeBracket = NSRange(location: contentLoc + typeRange.location + typeRange.length, length: closeLen) hide(closeBracket, ctx: ctx, into: &attrs) } return attrs } + /// Collapse a callout body line to an invisible 1pt sliver (same trick as + /// front-matter hiding). + private static func hideLine(_ range: NSRange, ctx: StylingContext, into attrs: inout [StyledRange]) { + let collapsed = NSMutableParagraphStyle() + collapsed.minimumLineHeight = 1 + collapsed.maximumLineHeight = 1 + collapsed.lineSpacing = 0 + collapsed.paragraphSpacing = 0 + collapsed.paragraphSpacingBefore = 0 + var paraAttrs: [StyledRange] = [] + ctx.nsText.enumerateSubstrings(in: range, options: .byParagraphs) { _, _, enclosing, _ in + paraAttrs.append((enclosing, [.paragraphStyle: collapsed])) + } + attrs.append(contentsOf: paraAttrs) + attrs.append((range, [.foregroundColor: NSColor.clear, .font: ctx.latexMarkerFont, .spellingState: 0])) + } + private static func boldFont(_ font: NSFont) -> NSFont { let merged = font.fontDescriptor.symbolicTraits.union(.bold) return NSFont(descriptor: font.fontDescriptor.withSymbolicTraits(merged), size: font.pointSize) ?? font @@ -104,4 +147,22 @@ extension MarkdownStyler { default: return .systemBlue // note, info, todo, abstract, … } } + + /// SF Symbol name drawn in the callout header's gutter. + private static func calloutIcon(for type: String) -> String { + switch type { + case "tip", "hint": return "flame" + case "success", "check", "done": return "checkmark.circle" + case "warning", "caution", "attention": return "exclamationmark.triangle" + case "failure", "fail", "danger", "error", "missing": return "xmark.circle" + case "bug": return "ladybug" + case "example": return "list.bullet" + case "quote", "cite": return "quote.opening" + case "question", "help", "faq": return "questionmark.circle" + case "todo": return "checklist" + case "summary", "abstract", "tldr": return "text.append" + case "important": return "exclamationmark.circle" + default: return "pencil.circle" // note, info, … + } + } } diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CalloutFold.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CalloutFold.swift new file mode 100644 index 00000000..ebeebf20 --- /dev/null +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CalloutFold.swift @@ -0,0 +1,62 @@ +// +// NativeTextView+CalloutFold.swift +// MarkdownEngine +// +// Hit-test the gutter of a collapsible callout's header line and toggle its +// fold marker (`[!type]-` ⇄ `[!type]+`) in the source, then restyle so the +// body collapses/expands. Mirrors the task-checkbox click handling. +// + +import AppKit + +extension NativeTextView { + func toggleCalloutFoldIfHit(event: NSEvent) -> Bool { + guard let textContainer = textContainer, + let bridge = layoutBridge, + let storage = textStorage else { return false } + let localPoint = convert(event.locationInWindow, from: nil) + let containerPoint = CGPoint( + x: localPoint.x - textContainerOrigin.x, + y: localPoint.y - textContainerOrigin.y + ) + + let fullRange = NSRange(location: 0, length: storage.length) + var hitRange: NSRange? = nil + storage.enumerateAttribute(.calloutFold, in: fullRange, options: []) { value, attrRange, stop in + guard value is Bool else { return } + let rect = bridge.boundingRect(forCharacterRange: attrRange, in: textContainer) + // The chevron sits in the gutter to the LEFT of the header text; accept + // clicks from a little before the text up to just inside it. + let gutter = CGRect(x: rect.minX - 26, y: rect.minY, width: 30, height: rect.height) + if gutter.contains(containerPoint) { + hitRange = attrRange + stop.pointee = true + } + } + guard let headerRange = hitRange else { return false } + + // Re-parse the header line to locate the `]±` marker and toggle it. + let nsText = storage.string as NSString + let lineRange = nsText.lineRange(for: NSRange(location: headerRange.location, length: 0)) + let line = nsText.substring(with: lineRange) + guard let bracket = line.range(of: #"\]([+-]?)"#, options: .regularExpression) else { return false } + let markerStart = lineRange.location + line.distance(from: line.startIndex, to: bracket.lowerBound) + 1 + let existing = nsText.substring(with: NSRange(location: markerStart, length: min(1, NSMaxRange(lineRange) - markerStart))) + let (oldMarkerRange, replacement): (NSRange, String) + switch existing { + case "-": (oldMarkerRange, replacement) = (NSRange(location: markerStart, length: 1), "+") // expand + case "+": (oldMarkerRange, replacement) = (NSRange(location: markerStart, length: 1), "-") // collapse + default: return false // not a foldable header + } + + if shouldChangeText(in: oldMarkerRange, replacementString: replacement) { + storage.replaceCharacters(in: oldMarkerRange, with: replacement) + didChangeText() + if let coord = delegate as? NativeTextViewCoordinator { + let fullRange = NSRange(location: 0, length: storage.length) + coord.restyleParagraphs([fullRange], in: self) + } + } + return true + } +} diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+DragSelectBoost.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+DragSelectBoost.swift index 79232488..e557406a 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+DragSelectBoost.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+DragSelectBoost.swift @@ -20,6 +20,7 @@ extension NativeTextView { return ts.attribute(.link, at: idx, effectiveRange: nil) != nil }() if let toggled = toggleTaskCheckboxIfHit(event: event), toggled { return } + if toggleCalloutFoldIfHit(event: event) { return } if remapClickInParagraphSpacing(event: event) { return } dragStartMouseScreenLoc = NSEvent.mouseLocation let boostTimer = Timer(timeInterval: 1.0 / configuration.dragSelection.ticksPerSecond, repeats: true) { [weak self] _ in From 45be807ed96eb7953ab37a3cfd871a14efbb1581 Mon Sep 17 00:00:00 2001 From: Christine Tham Date: Sun, 12 Jul 2026 09:40:59 +1000 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../TextView/NativeTextView/NativeTextView+CalloutFold.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CalloutFold.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CalloutFold.swift index ebeebf20..dc8c35d0 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CalloutFold.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CalloutFold.swift @@ -23,7 +23,7 @@ extension NativeTextView { let fullRange = NSRange(location: 0, length: storage.length) var hitRange: NSRange? = nil storage.enumerateAttribute(.calloutFold, in: fullRange, options: []) { value, attrRange, stop in - guard value is Bool else { return } + guard (value as? Bool) != nil else { return } let rect = bridge.boundingRect(forCharacterRange: attrRange, in: textContainer) // The chevron sits in the gutter to the LEFT of the header text; accept // clicks from a little before the text up to just inside it.