Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ 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")
/// 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 {
Expand Down Expand Up @@ -76,6 +85,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)

Expand Down Expand Up @@ -492,6 +504,68 @@ 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()

// 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

/// Paint a `•` over every hidden bullet marker (`.bulletMarker`). The
Expand Down
168 changes: 168 additions & 0 deletions Sources/MarkdownEngine/Styling/MarkdownStyler+Callouts.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
//
// 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)

// 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.
// 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.
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)
// 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
}

/// 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, …
}
}

/// 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, …
}
}
}
54 changes: 54 additions & 0 deletions Sources/MarkdownEngine/Styling/MarkdownStyler+Comments.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading