A no-Xcode AppKit app (swiftc + build.sh): - Plaintext editor: 5 themes, line numbers, soft wrap, font controls, find, live Markdown preview (split mode), selection-aware Markdown toolbar. - Freeform Board mode: zoomable/pannable canvas of page cards (portrait/landscape, US Letter/A4/Legal/Square/Wide/Note), clean boxes, images (paste/drop/panel), freehand pen/shapes, screen-grab eyedropper, nested right-click menu, rich status bar. The Editor follows the selected page; layout persists to a foo.txt.board sidecar; the .txt stays pure plaintext. - scp-on-save to a Tailscale Mac (key auth, in-flight/quit drain). - HTML scraping pipeline: custom selector engine + Discogs profile, ingests to Postgres (psql) into a marketplace_snapshots schema. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
293 lines
12 KiB
Swift
293 lines
12 KiB
Swift
import AppKit
|
|
|
|
/// A pragmatic Markdown → NSAttributedString renderer for the live preview.
|
|
/// Handles the constructs people actually type: ATX headings, bold/italic/
|
|
/// bold-italic, inline code, fenced & indented code, blockquotes, ordered and
|
|
/// unordered lists, horizontal rules, and links. Colors come from the theme.
|
|
enum MarkdownRenderer {
|
|
|
|
static func render(_ markdown: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString {
|
|
let out = NSMutableAttributedString()
|
|
let lines = markdown.components(separatedBy: "\n")
|
|
var i = 0
|
|
var inFence = false
|
|
var fenceBuffer: [String] = []
|
|
|
|
func flushFence() {
|
|
let code = fenceBuffer.joined(separator: "\n")
|
|
out.append(codeBlock(code, theme: theme, baseSize: baseSize))
|
|
fenceBuffer = []
|
|
}
|
|
|
|
while i < lines.count {
|
|
let line = lines[i]
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
if trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") {
|
|
if inFence { flushFence(); inFence = false } else { inFence = true }
|
|
i += 1
|
|
continue
|
|
}
|
|
if inFence { fenceBuffer.append(line); i += 1; continue }
|
|
|
|
// Horizontal rule
|
|
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
|
|
out.append(horizontalRule(theme: theme, baseSize: baseSize))
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
// ATX heading
|
|
if let (level, content) = heading(trimmed) {
|
|
out.append(headingLine(content, level: level, theme: theme, baseSize: baseSize))
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
// Blockquote
|
|
if trimmed.hasPrefix(">") {
|
|
let content = String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces)
|
|
out.append(blockquote(content, theme: theme, baseSize: baseSize))
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
// Unordered list item
|
|
if let marker = unorderedMarker(trimmed) {
|
|
let content = String(trimmed.dropFirst(marker.count)).trimmingCharacters(in: .whitespaces)
|
|
out.append(listItem(bullet: "• ", content: content, theme: theme, baseSize: baseSize))
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
// Ordered list item
|
|
if let (number, content) = orderedItem(trimmed) {
|
|
out.append(listItem(bullet: "\(number). ", content: content, theme: theme, baseSize: baseSize))
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
// Blank line → paragraph spacing
|
|
if trimmed.isEmpty {
|
|
out.append(NSAttributedString(string: "\n", attributes: [.font: bodyFont(baseSize)]))
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
// Paragraph
|
|
let para = inline(trimmed, theme: theme, baseSize: baseSize, font: bodyFont(baseSize))
|
|
out.append(para)
|
|
out.append(NSAttributedString(string: "\n", attributes: [.font: bodyFont(baseSize)]))
|
|
i += 1
|
|
}
|
|
if inFence { flushFence() }
|
|
// Every builder already sets its own foreground color, so there's no
|
|
// blanket recolor here — that would clobber links, code, and accents.
|
|
return out
|
|
}
|
|
|
|
// MARK: - Block builders
|
|
|
|
private static func bodyFont(_ size: CGFloat) -> NSFont { NSFont.systemFont(ofSize: size) }
|
|
private static func monoFont(_ size: CGFloat) -> NSFont { NSFont.monospacedSystemFont(ofSize: size, weight: .regular) }
|
|
|
|
private static func heading(_ s: String) -> (Int, String)? {
|
|
var level = 0
|
|
for ch in s {
|
|
if ch == "#" { level += 1 } else { break }
|
|
}
|
|
guard level >= 1, level <= 6 else { return nil }
|
|
let rest = s.dropFirst(level)
|
|
guard rest.first == " " || rest.isEmpty else { return nil }
|
|
return (level, rest.trimmingCharacters(in: .whitespaces))
|
|
}
|
|
|
|
private static func unorderedMarker(_ s: String) -> String? {
|
|
for m in ["- ", "* ", "+ "] where s.hasPrefix(m) { return String(m.dropLast()) }
|
|
return nil
|
|
}
|
|
|
|
private static func orderedItem(_ s: String) -> (Int, String)? {
|
|
var digits = ""
|
|
for ch in s {
|
|
if ch.isNumber { digits.append(ch) } else { break }
|
|
}
|
|
guard let n = Int(digits) else { return nil }
|
|
let rest = s.dropFirst(digits.count)
|
|
guard rest.first == "." || rest.first == ")" else { return nil }
|
|
let content = rest.dropFirst().trimmingCharacters(in: .whitespaces)
|
|
return (n, content)
|
|
}
|
|
|
|
private static func headingLine(_ content: String, level: Int, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString {
|
|
let scale: CGFloat = [1.9, 1.55, 1.3, 1.15, 1.05, 1.0][min(level - 1, 5)]
|
|
let weight: NSFont.Weight = level <= 2 ? .bold : .semibold
|
|
let font = NSFont.systemFont(ofSize: round(baseSize * scale), weight: weight)
|
|
let para = NSMutableParagraphStyle()
|
|
para.paragraphSpacingBefore = baseSize * 0.6
|
|
para.paragraphSpacing = baseSize * 0.3
|
|
let attr = inline(content, theme: theme, baseSize: baseSize, font: font)
|
|
let m = NSMutableAttributedString(attributedString: attr)
|
|
m.addAttributes([.paragraphStyle: para], range: NSRange(location: 0, length: m.length))
|
|
m.append(NSAttributedString(string: "\n"))
|
|
return m
|
|
}
|
|
|
|
private static func codeBlock(_ code: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString {
|
|
let para = NSMutableParagraphStyle()
|
|
para.firstLineHeadIndent = 12
|
|
para.headIndent = 12
|
|
para.paragraphSpacingBefore = baseSize * 0.3
|
|
para.paragraphSpacing = baseSize * 0.3
|
|
let bg = theme.isDark ? NSColor.white.withAlphaComponent(0.06) : NSColor.black.withAlphaComponent(0.05)
|
|
let attrs: [NSAttributedString.Key: Any] = [
|
|
.font: monoFont(baseSize - 1),
|
|
.foregroundColor: theme.foreground,
|
|
.backgroundColor: bg,
|
|
.paragraphStyle: para,
|
|
]
|
|
return NSAttributedString(string: code + "\n", attributes: attrs)
|
|
}
|
|
|
|
private static func blockquote(_ content: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString {
|
|
let para = NSMutableParagraphStyle()
|
|
para.firstLineHeadIndent = 14
|
|
para.headIndent = 14
|
|
para.paragraphSpacingBefore = 2
|
|
para.paragraphSpacing = 2
|
|
let attr = inline(content, theme: theme, baseSize: baseSize,
|
|
font: NSFontManager.shared.convert(bodyFont(baseSize), toHaveTrait: .italicFontMask))
|
|
let m = NSMutableAttributedString(attributedString: attr)
|
|
m.addAttributes([.paragraphStyle: para, .foregroundColor: theme.secondaryText],
|
|
range: NSRange(location: 0, length: m.length))
|
|
m.append(NSAttributedString(string: "\n"))
|
|
return m
|
|
}
|
|
|
|
private static func listItem(bullet: String, content: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString {
|
|
let para = NSMutableParagraphStyle()
|
|
para.firstLineHeadIndent = 16
|
|
para.headIndent = 16 + (bullet as NSString).size(withAttributes: [.font: bodyFont(baseSize)]).width
|
|
let m = NSMutableAttributedString()
|
|
m.append(NSAttributedString(string: bullet, attributes: [
|
|
.font: bodyFont(baseSize), .foregroundColor: theme.accent, .paragraphStyle: para,
|
|
]))
|
|
let body = inline(content, theme: theme, baseSize: baseSize, font: bodyFont(baseSize))
|
|
let bodyM = NSMutableAttributedString(attributedString: body)
|
|
bodyM.addAttribute(.paragraphStyle, value: para, range: NSRange(location: 0, length: bodyM.length))
|
|
m.append(bodyM)
|
|
m.append(NSAttributedString(string: "\n"))
|
|
return m
|
|
}
|
|
|
|
private static func horizontalRule(theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString {
|
|
let para = NSMutableParagraphStyle()
|
|
para.paragraphSpacingBefore = baseSize * 0.4
|
|
para.paragraphSpacing = baseSize * 0.4
|
|
return NSAttributedString(string: "________________________\n", attributes: [
|
|
.font: bodyFont(baseSize),
|
|
.foregroundColor: theme.secondaryText.withAlphaComponent(0.4),
|
|
.paragraphStyle: para,
|
|
])
|
|
}
|
|
|
|
// MARK: - Inline parsing
|
|
|
|
/// Parses bold/italic/code/links inside a single line. `font` is the base
|
|
/// font for this block (so headings keep their size while going bold etc).
|
|
private static func inline(_ text: String, theme: EditorTheme, baseSize: CGFloat, font: NSFont) -> NSAttributedString {
|
|
let result = NSMutableAttributedString()
|
|
let chars = Array(text)
|
|
var i = 0
|
|
var plainStart = 0
|
|
|
|
func flushPlain(upTo end: Int) {
|
|
if end > plainStart {
|
|
let s = String(chars[plainStart..<end])
|
|
result.append(NSAttributedString(string: s, attributes: [.font: font, .foregroundColor: theme.foreground]))
|
|
}
|
|
}
|
|
|
|
func emphasisFont(bold: Bool, italic: Bool) -> NSFont {
|
|
var f = font
|
|
let mgr = NSFontManager.shared
|
|
if bold { f = mgr.convert(f, toHaveTrait: .boldFontMask) }
|
|
if italic { f = mgr.convert(f, toHaveTrait: .italicFontMask) }
|
|
return f
|
|
}
|
|
|
|
func matchRun(_ marker: Character, count: Int, from start: Int) -> Int? {
|
|
// Find a closing run of `count` markers after start.
|
|
var j = start
|
|
while j < chars.count {
|
|
if chars[j] == marker {
|
|
var run = 0
|
|
while j + run < chars.count && chars[j + run] == marker { run += 1 }
|
|
if run >= count { return j }
|
|
j += run
|
|
} else {
|
|
j += 1
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
while i < chars.count {
|
|
let c = chars[i]
|
|
// Inline code `...`
|
|
if c == "`" {
|
|
if let close = chars[(i + 1)...].firstIndex(of: "`") {
|
|
flushPlain(upTo: i)
|
|
let codeText = String(chars[(i + 1)..<close])
|
|
let bg = theme.isDark ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06)
|
|
result.append(NSAttributedString(string: codeText, attributes: [
|
|
.font: monoFont(baseSize - 1), .foregroundColor: theme.accent, .backgroundColor: bg,
|
|
]))
|
|
i = close + 1
|
|
plainStart = i
|
|
continue
|
|
}
|
|
}
|
|
// Bold/italic ** * __ _
|
|
if c == "*" || c == "_" {
|
|
var run = 0
|
|
while i + run < chars.count && chars[i + run] == c { run += 1 }
|
|
let count = min(run, 3)
|
|
if let close = matchRun(c, count: count, from: i + count) {
|
|
flushPlain(upTo: i)
|
|
let innerText = String(chars[(i + count)..<close])
|
|
let bold = count >= 2
|
|
let italic = count == 1 || count == 3
|
|
let inner = inline(innerText, theme: theme, baseSize: baseSize, font: emphasisFont(bold: bold, italic: italic))
|
|
result.append(inner)
|
|
i = close + count
|
|
plainStart = i
|
|
continue
|
|
}
|
|
}
|
|
// Link [text](url)
|
|
if c == "[" {
|
|
if let closeBracket = chars[(i + 1)...].firstIndex(of: "]"),
|
|
closeBracket + 1 < chars.count, chars[closeBracket + 1] == "(",
|
|
let closeParen = chars[(closeBracket + 2)...].firstIndex(of: ")") {
|
|
flushPlain(upTo: i)
|
|
let label = String(chars[(i + 1)..<closeBracket])
|
|
let url = String(chars[(closeBracket + 2)..<closeParen])
|
|
var attrs: [NSAttributedString.Key: Any] = [
|
|
.font: font, .foregroundColor: theme.accent,
|
|
.underlineStyle: NSUnderlineStyle.single.rawValue,
|
|
]
|
|
if let u = URL(string: url) { attrs[.link] = u }
|
|
result.append(NSAttributedString(string: label, attributes: attrs))
|
|
i = closeParen + 1
|
|
plainStart = i
|
|
continue
|
|
}
|
|
}
|
|
i += 1
|
|
}
|
|
flushPlain(upTo: chars.count)
|
|
return result
|
|
}
|
|
}
|