Skip to content
Draft
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Checked-task treatment knobs: `TaskCheckboxStyle.strikethroughCompletedTasks`
gates the completed label's strikethrough (default on, as before),
`MarkdownEditorTheme.completedTaskText` colors the completed label (nil =
body ink; inline constructs keep their own colors), and
`MarkdownEditorTheme.taskCheckboxChecked` / `taskCheckboxUnchecked` tint the
drawn checkbox symbols (nil = the historical `bodyText` / `mutedText`).
- Custom heading typeface and color: `HeadingStyle.fontName` renders headings
in a specific PostScript face (honored exactly, so the chosen weight is
respected; an unresolvable name falls back to the stock bold base font),
and `MarkdownEditorTheme.headingText` colors heading text independently of
`bodyText` — the `#` glyphs stay on `headingMarker`, and inline constructs
inside a heading keep their own ink (both opt-in; the defaults are
unchanged).

## [0.11.0] - 2026-07-31

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,20 +325,28 @@ public struct ListStyle: Sendable {
/// example `"circle"` / `"checkmark.circle.fill"`. A name that doesn't
/// resolve falls back to the corresponding default symbol at draw time, so a
/// typo degrades to the stock look instead of drawing nothing. Tint colors
/// stay theme-driven (`MarkdownEditorTheme/mutedText` unchecked,
/// `MarkdownEditorTheme/bodyText` checked).
/// are theme-driven: ``MarkdownEditorTheme/taskCheckboxUnchecked`` /
/// ``MarkdownEditorTheme/taskCheckboxChecked``, which default to
/// `mutedText` unchecked and `bodyText` checked.
public struct TaskCheckboxStyle: Sendable {
/// SF Symbol drawn for an unchecked task item (`[ ]`).
public var uncheckedSymbolName: String
/// SF Symbol drawn for a checked task item (`[x]`).
public var checkedSymbolName: String
/// Whether a checked item's label is struck through. `true` (the
/// default) keeps the historical rendering. Designs that mark completion
/// by ink alone can turn this off and set
/// ``MarkdownEditorTheme/completedTaskText`` instead.
public var strikethroughCompletedTasks: Bool

public init(
uncheckedSymbolName: String = "square",
checkedSymbolName: String = "checkmark.square.fill"
checkedSymbolName: String = "checkmark.square.fill",
strikethroughCompletedTasks: Bool = true
) {
self.uncheckedSymbolName = uncheckedSymbolName
self.checkedSymbolName = checkedSymbolName
self.strikethroughCompletedTasks = strikethroughCompletedTasks
}

public static let `default` = TaskCheckboxStyle()
Expand All @@ -349,15 +357,30 @@ public struct TaskCheckboxStyle: Sendable {
/// Per-level heading metrics. Defaults follow the historical Nodes ratios,
/// which are loosely based on browser default heading sizes.
public struct HeadingStyle: Sendable {
/// PostScript name of the typeface used for heading text, for example
/// `"AvenirNext-DemiBold"`. `nil` (the default) keeps the historical
/// behavior: headings render in the editor's base font with the bold
/// trait added.
///
/// The name is honored exactly, so the chosen face's weight and style
/// are respected — pick a `-Bold` / `-Semibold` face for heavier
/// headings. Emphasis inside a heading still composes on top of it:
/// bold / italic add their traits while the family and the per-level
/// size are kept. A name that doesn't resolve falls back to the default
/// heading font at draw time, so a typo degrades to the stock look
/// instead of changing metrics.
public var fontName: String?
/// Font-size multiplier per heading level (1...6).
public var fontMultipliers: [CGFloat]
/// Top spacing in `em` units per heading level (1...6).
public var topSpacingEm: [CGFloat]

public init(
fontName: String? = nil,
fontMultipliers: [CGFloat] = [2.0, 1.5, 1.17, 1.0, 0.83, 0.67],
topSpacingEm: [CGFloat] = [0.35, 0.30, 0.25, 0.20, 0.15, 0.10]
) {
self.fontName = fontName
self.fontMultipliers = fontMultipliers
self.topSpacingEm = topSpacingEm
}
Expand Down
34 changes: 34 additions & 0 deletions Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,34 @@ public struct MarkdownEditorTheme: Sendable {
/// Foreground color for content the engine wants to deemphasize further
/// than `mutedText` — for example, broken wiki-links.
public var disabledText: NSColor
/// Foreground color for heading text. `nil` (the default) keeps the
/// historical behavior: headings render in ``bodyText`` like the rest
/// of the document.
///
/// Only the heading's own text takes this color. The `#` marker glyphs
/// stay on ``headingMarker``, and inline constructs inside a heading
/// (links, inline code, extension spans) keep their own colors, exactly
/// as they do over ``bodyText``.
public var headingText: NSColor?
/// Foreground color for heading marker glyphs (`#`, `##`, …).
public var headingMarker: NSColor
/// Foreground color for a CHECKED task item's label. `nil` (the default)
/// keeps the historical behavior: the label stays on the document's body
/// ink. Inline constructs inside the label (links, code, extension
/// spans) keep their own colors either way, exactly as they do over
/// ``bodyText``. Pairs with
/// ``TaskCheckboxStyle/strikethroughCompletedTasks`` for designs that
/// mark completion by ink instead of a strikethrough.
public var completedTaskText: NSColor?

// MARK: Task checkboxes

/// Tint of the drawn checkbox symbol for a CHECKED task item. `nil`
/// (the default) keeps the historical ``bodyText`` tint.
public var taskCheckboxChecked: NSColor?
/// Tint of the drawn checkbox symbol for an UNCHECKED task item. `nil`
/// (the default) keeps the historical ``mutedText`` tint.
public var taskCheckboxUnchecked: NSColor?

// MARK: Links

Expand Down Expand Up @@ -85,7 +111,11 @@ public struct MarkdownEditorTheme: Sendable {
bodyText: NSColor = .labelColor,
mutedText: NSColor = .secondaryLabelColor,
disabledText: NSColor = .tertiaryLabelColor,
headingText: NSColor? = nil,
headingMarker: NSColor = .gray,
completedTaskText: NSColor? = nil,
taskCheckboxChecked: NSColor? = nil,
taskCheckboxUnchecked: NSColor? = nil,
link: NSColor = .linkColor,
incompleteLink: NSColor = .systemBlue,
findMatchHighlight: NSColor = .systemYellow,
Expand All @@ -98,7 +128,11 @@ public struct MarkdownEditorTheme: Sendable {
self.bodyText = bodyText
self.mutedText = mutedText
self.disabledText = disabledText
self.headingText = headingText
self.headingMarker = headingMarker
self.completedTaskText = completedTaskText
self.taskCheckboxChecked = taskCheckboxChecked
self.taskCheckboxUnchecked = taskCheckboxUnchecked
self.link = link
self.incompleteLink = incompleteLink
self.findMatchHighlight = findMatchHighlight
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,9 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {
if let baseSymbol = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)
?? NSImage(systemSymbolName: fallbackName, accessibilityDescription: nil) {
let sizeConfig = NSImage.SymbolConfiguration(pointSize: iconRect.height, weight: .regular)
let tint = isChecked ? configuration.theme.bodyText : configuration.theme.mutedText
let tint = isChecked
? (configuration.theme.taskCheckboxChecked ?? configuration.theme.bodyText)
: (configuration.theme.taskCheckboxUnchecked ?? configuration.theme.mutedText)
let colorConfig = NSImage.SymbolConfiguration(hierarchicalColor: tint)
let symbolConfig = sizeConfig.applying(colorConfig)
let symbol = baseSymbol.withSymbolConfiguration(symbolConfig) ?? baseSymbol
Expand Down
40 changes: 32 additions & 8 deletions Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -404,10 +404,20 @@ enum MarkdownASTStyler {
attrs.append((postGap, [.foregroundColor: NSColor.clear, .font: ctx.inlineMarkerFont]))
}
if item.checked, NSMaxRange(item.range) > NSMaxRange(box) {
attrs.append((NSRange(location: NSMaxRange(box), length: NSMaxRange(item.range) - NSMaxRange(box)), [
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
.strikethroughColor: ctx.theme.strikethroughColor,
]))
let label = NSRange(location: NSMaxRange(box), length: NSMaxRange(item.range) - NSMaxRange(box))
// theme.completedTaskText paints the whole label; inline
// constructs (links, code, extension spans) append LATER via
// styleInlines, so they keep their own ink — the same
// later-range-wins layering the bodyText default relies on.
if let completedInk = ctx.theme.completedTaskText {
attrs.append((label, [.foregroundColor: completedInk]))
}
if ctx.config.taskCheckbox.strikethroughCompletedTasks {
attrs.append((label, [
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
.strikethroughColor: ctx.theme.strikethroughColor,
]))
}
}
} else if !item.ordered {
let syntax = NSRange(location: item.marker.location,
Expand Down Expand Up @@ -548,17 +558,31 @@ enum MarkdownASTStyler {

case .heading(let level, let range, let markers, let inlines):
let multiplier = ctx.config.headings.fontMultiplier(for: level)
let headingBase = NSFont(name: ctx.fontName, size: ctx.baseFont.pointSize * multiplier)
?? .systemFont(ofSize: ctx.baseFont.pointSize * multiplier)
let headingFont = adding(.bold, to: headingBase)
let headingSize = ctx.baseFont.pointSize * multiplier
// A configured heading face is honored exactly — its weight is the
// embedder's choice, so no synthetic bold on top. A name that
// doesn't resolve degrades to the stock heading font (base family,
// bold trait), mirroring TaskCheckboxStyle's symbol fallback.
let headingFont = ctx.config.headings.fontName
.flatMap { NSFont(name: $0, size: headingSize) }
?? adding(.bold, to: NSFont(name: ctx.fontName, size: headingSize)
?? .systemFont(ofSize: headingSize))
let lineHeight = ceil(headingFont.ascender - headingFont.descender + headingFont.leading) + 1
let headingPara = NSMutableParagraphStyle()
headingPara.minimumLineHeight = lineHeight
headingPara.maximumLineHeight = lineHeight
headingPara.paragraphSpacingBefore = headingFont.pointSize * ctx.config.headings.topSpacingEm(for: level)
headingPara.paragraphSpacing = ctx.baseParagraphSpacing
attrs.append((ctx.ns.paragraphRange(for: range), [.paragraphStyle: headingPara]))
attrs.append((range, [.font: headingFont]))
// theme.headingText paints the whole heading line; the marker loop
// and the inline descent below both append LATER, so `#` glyphs
// keep headingMarker and links / code keep their own ink — the
// same later-range-wins layering the bodyText default relies on.
var headingAttrs: [NSAttributedString.Key: Any] = [.font: headingFont]
if let headingText = ctx.theme.headingText {
headingAttrs[.foregroundColor] = headingText
}
attrs.append((range, headingAttrs))
for marker in markers {
attrs.append((marker, [.foregroundColor: ctx.theme.headingMarker]))
}
Expand Down
179 changes: 179 additions & 0 deletions Tests/MarkdownEngineTests/HeadingFontAndColorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
//
// HeadingFontAndColorTests.swift
// MarkdownEngineTests
//
// The two opt-in heading knobs: `HeadingStyle.fontName` (a dedicated heading
// typeface) and `MarkdownEditorTheme.headingText` (a dedicated heading text
// color). Both default to nil, which must keep the stock styling unchanged —
// headings derive from the base font with the bold trait and inherit the
// view-level bodyText foreground.
//

import AppKit
import Foundation
import Testing
@testable import MarkdownEngine

@Suite("Heading font & color knobs")
struct HeadingFontAndColorTests {

private let base: CGFloat = 14
private var fontName: String { NSFont.systemFont(ofSize: 14).fontName }

/// A real, always-installed face that differs from the system font in both
/// family and weight, so assertions can see it was used verbatim.
private let headingFace = "Menlo-Regular"

/// Effective font at `pos`: the last styled range covering it that sets `.font`.
private func font(in attrs: [StyledRange], at pos: Int) -> NSFont? {
var result: NSFont?
for (range, a) in attrs where NSLocationInRange(pos, range) {
if let f = a[.font] as? NSFont { result = f }
}
return result
}

/// Effective color at `pos`: the last styled range covering it that sets `.foregroundColor`.
private func color(in attrs: [StyledRange], at pos: Int) -> NSColor? {
var result: NSColor?
for (range, a) in attrs where NSLocationInRange(pos, range) {
if let c = a[.foregroundColor] as? NSColor { result = c }
}
return result
}

private func style(
_ text: String,
configuration: MarkdownEditorConfiguration = .default
) -> [StyledRange] {
MarkdownASTStyler.styleAttributes(
text: text, fontName: fontName, fontSize: base, configuration: configuration
)
}

// MARK: - HeadingStyle.fontName

@Test("headings.fontName renders headings in that face at the multiplied size")
func headingFontNameUsedVerbatimAtMultipliedSize() {
let config = MarkdownEditorConfiguration(headings: HeadingStyle(fontName: headingFace))
// "# One\n\nbody\n\n## Two": O=2, b=7, T=16
let attrs = style("# One\n\nbody\n\n## Two", configuration: config)

let h1 = font(in: attrs, at: 2)
#expect(h1?.fontName == headingFace)
#expect(h1?.pointSize == base * 2.0)
// The face is honored exactly: no synthetic bold on the chosen weight.
#expect(h1?.fontDescriptor.symbolicTraits.contains(.bold) == false)

// Per-level multipliers still apply to the custom face.
let h2 = font(in: attrs, at: 16)
#expect(h2?.fontName == headingFace)
#expect(h2?.pointSize == base * 1.5)

// Body text never takes the heading face (no .font range at all).
#expect(font(in: attrs, at: 7) == nil)
}

@Test("emphasis inside a custom-face heading keeps family and size, adds traits")
func emphasisComposesOnTheCustomHeadingFace() {
let config = MarkdownEditorConfiguration(headings: HeadingStyle(fontName: headingFace))
// "# **n*o*des**": n=4, o=6, d=8
let attrs = style("# **n*o*des**", configuration: config)
let n = font(in: attrs, at: 4)
let o = font(in: attrs, at: 6)
let d = font(in: attrs, at: 8)

#expect(n?.familyName == "Menlo")
#expect(o?.familyName == "Menlo")
#expect(d?.familyName == "Menlo")
#expect(n?.pointSize == base * 2.0)
#expect(o?.pointSize == base * 2.0)
#expect(d?.pointSize == base * 2.0)
#expect(n?.fontDescriptor.symbolicTraits.contains(.bold) == true)
#expect(o?.fontDescriptor.symbolicTraits.contains([.bold, .italic]) == true)
#expect(d?.fontDescriptor.symbolicTraits.contains(.bold) == true)
}

@Test("an unresolvable fontName falls back to the stock heading font")
func unresolvableFontNameFallsBack() {
let config = MarkdownEditorConfiguration(
headings: HeadingStyle(fontName: "Not-A-Real-Font-Face")
)
let stock = font(in: style("# Title"), at: 2)
let fallback = font(in: style("# Title", configuration: config), at: 2)
#expect(fallback == stock)
#expect(fallback?.fontDescriptor.symbolicTraits.contains(.bold) == true)
}

// MARK: - MarkdownEditorTheme.headingText

@Test("theme.headingText colors heading text; # markers and body keep their own ink")
func headingTextColorsContentOnly() {
var theme = MarkdownEditorTheme.default
theme.headingText = .systemPink
let config = MarkdownEditorConfiguration(theme: theme)
// "# Title\n\nbody": marker=0..1, T=2, b=8
let attrs = style("# Title\n\nbody", configuration: config)

#expect(color(in: attrs, at: 2) == .systemPink)
// The `#` marker glyphs stay on headingMarker (the separate knob).
#expect(color(in: attrs, at: 0) == theme.headingMarker)
// Body text still inherits the view-level bodyText (no styled foreground).
#expect(color(in: attrs, at: 8) == nil)
}

@Test("a link inside a colored heading keeps the link ink")
func linkInsideColoredHeadingKeepsLinkColor() {
var theme = MarkdownEditorTheme.default
theme.headingText = .systemPink
let config = MarkdownEditorConfiguration(theme: theme)
// "# [x](https://e.com)": x=3
let attrs = style("# [x](https://e.com)", configuration: config)
#expect(color(in: attrs, at: 3) == theme.link)
}

@Test("emphasis inside a colored heading keeps the heading color")
func emphasisInsideColoredHeadingKeepsHeadingColor() {
var theme = MarkdownEditorTheme.default
theme.headingText = .systemPink
let config = MarkdownEditorConfiguration(theme: theme)
// "# **bold**": b=4 — emphasis composes fonts only, so the ink survives.
let attrs = style("# **bold**", configuration: config)
#expect(color(in: attrs, at: 4) == .systemPink)
}

// MARK: - Defaults stay byte-identical

@Test("nil knobs: heading content carries the stock font and no foreground")
func nilKnobsKeepStockHeadingAttributes() {
// "# Title": T=2
let attrs = style("# Title")
let heading = font(in: attrs, at: 2)
let stock = NSFont(name: fontName, size: base * 2.0) ?? .systemFont(ofSize: base * 2.0)
let stockBold = NSFont(
descriptor: stock.fontDescriptor.withSymbolicTraits(
stock.fontDescriptor.symbolicTraits.union(.bold)),
size: stock.pointSize
) ?? stock
#expect(heading == stockBold)
// No styled range sets a heading foreground — bodyText inheritance.
#expect(color(in: attrs, at: 2) == nil)
}

@Test("explicit-nil knobs produce value-identical styling to .default")
func nilKnobsMatchDefaultsExactly() {
let doc = "# One **bold** *i*\n\nbody `code`\n\n## Two\n\n- item\n\n> quote\n"
let expected = style(doc)
let explicitNil = MarkdownEditorConfiguration(
theme: MarkdownEditorTheme(headingText: nil),
headings: HeadingStyle(fontName: nil)
)
let actual = style(doc, configuration: explicitNil)

#expect(actual.count == expected.count)
for (a, e) in zip(actual, expected) {
#expect(a.range == e.range)
#expect((a.attributes as NSDictionary).isEqual(to: e.attributes))
}
}
}
Loading