Skip to content
Closed
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
- Blockquote styling knobs: `BlockquoteStyle.barWidth` / `textIndent` expose
the previously hard-coded bar width and per-level indent (defaults 3/18 as
before), `MarkdownEditorTheme.blockquoteBar` colors the painted bar (nil =
the historical half-transparent muted ink), and
`MarkdownEditorTheme.blockquoteText` lifts the historical content muting
(nil = muted, as before; revealed `>` markers stay muted either way).
- 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 @@ -349,15 +349,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 Expand Up @@ -445,7 +460,7 @@ public struct InlineLatexStyle: Sendable {

// MARK: - Blockquote

/// Extra line height added to blockquote lines.
/// Metrics for blockquote lines.
///
/// By default blockquote lines use the font's natural line height with no
/// extra spacing. Set `extraLineHeight` to add breathing room, matching
Expand All @@ -454,9 +469,23 @@ public struct InlineLatexStyle: Sendable {
public struct BlockquoteStyle: Sendable {
/// Extra height (points) added to the default line height for blockquote lines.
public var extraLineHeight: CGFloat
/// Width (points) of each painted vertical quote bar.
public var barWidth: CGFloat
/// Horizontal space (points) each blockquote nesting level occupies.
/// A level-`n` quote's text hangs at `n × textIndent + textIndent / 2`
/// and the level-`i` bar paints `textIndent / 4` into its slot — the
/// historical geometry, now tunable. The defaults (3pt bar, 18pt
/// indent) reproduce the previous hard-coded constants exactly.
public var textIndent: CGFloat

public init(extraLineHeight: CGFloat = 0) {
public init(
extraLineHeight: CGFloat = 0,
barWidth: CGFloat = 3,
textIndent: CGFloat = 18
) {
self.extraLineHeight = extraLineHeight
self.barWidth = barWidth
self.textIndent = textIndent
}

public static let `default` = BlockquoteStyle()
Expand Down
27 changes: 27 additions & 0 deletions Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,30 @@ 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

// MARK: Blockquotes

/// Fill of the painted vertical quote bar(s). `nil` (the default) keeps
/// the historical half-transparent ``mutedText``. A configured color is
/// used exactly as given — no alpha is layered on top.
public var blockquoteBar: NSColor?
/// Foreground color of blockquote content. `nil` (the default) keeps the
/// historical behavior of muting quotes in ``mutedText``. The `>` marker
/// glyphs revealed on the active line stay on ``mutedText`` either way,
/// and inline constructs keep their own colors as usual.
public var blockquoteText: NSColor?

// MARK: Links

/// Foreground color for hyperlinks that resolve to an URL.
Expand Down Expand Up @@ -85,7 +106,10 @@ public struct MarkdownEditorTheme: Sendable {
bodyText: NSColor = .labelColor,
mutedText: NSColor = .secondaryLabelColor,
disabledText: NSColor = .tertiaryLabelColor,
headingText: NSColor? = nil,
headingMarker: NSColor = .gray,
blockquoteBar: NSColor? = nil,
blockquoteText: NSColor? = nil,
link: NSColor = .linkColor,
incompleteLink: NSColor = .systemBlue,
findMatchHighlight: NSColor = .systemYellow,
Expand All @@ -98,7 +122,10 @@ public struct MarkdownEditorTheme: Sendable {
self.bodyText = bodyText
self.mutedText = mutedText
self.disabledText = disabledText
self.headingText = headingText
self.headingMarker = headingMarker
self.blockquoteBar = blockquoteBar
self.blockquoteText = blockquoteText
self.link = link
self.incompleteLink = incompleteLink
self.findMatchHighlight = findMatchHighlight
Expand Down
18 changes: 8 additions & 10 deletions Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,6 @@ extension NSAttributedString.Key {

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

/// Strip below an overlay block for the legacy-small scroller (~11pt) + buffer.
static let scrollableBlockScrollerStrip: CGFloat = 14

Expand Down Expand Up @@ -474,16 +469,19 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {
}
guard anyLevel else { return }

let theme = (textLayoutManager?.textContainer?.textView as? NativeTextView)?
.configuration.theme ?? .default
let indentPerLevel = Self.blockquoteIndentPerLevel
let barWidth = Self.blockquoteBarWidth
let configuration = (textLayoutManager?.textContainer?.textView as? NativeTextView)?
.configuration ?? .default
let theme = configuration.theme
// textIndent is shared with the styler's paragraph indent so the
// painted bars and the hanging text line up at every level.
let indentPerLevel = configuration.blockquote.textIndent
let barWidth = configuration.blockquote.barWidth

NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
let nsContext = NSGraphicsContext(cgContext: context, flipped: true)
NSGraphicsContext.current = nsContext
theme.mutedText.withAlphaComponent(0.5).setFill()
(theme.blockquoteBar ?? theme.mutedText.withAlphaComponent(0.5)).setFill()

let fragLocation = fragmentNSRange?.location ?? 0
let leftEdge = point.x - layoutFragmentFrame.origin.x
Expand Down
28 changes: 22 additions & 6 deletions Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -548,17 +548,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 Expand Up @@ -617,7 +631,7 @@ enum MarkdownASTStyler {

/// Per-line blockquote: indent, mute content, hide/show `>` markers, tag first char with bar level.
private static func styleBlockquote(range: NSRange, ctx: Ctx, into attrs: inout [StyledRange]) {
let indentPerLevel = MarkdownTextLayoutFragment.blockquoteIndentPerLevel
let indentPerLevel = ctx.config.blockquote.textIndent
var lineStart = range.location
let end = NSMaxRange(range)
while lineStart < end {
Expand Down Expand Up @@ -660,7 +674,9 @@ enum MarkdownASTStyler {
attrs.append((ctx.ns.paragraphRange(for: tokenRange), [.paragraphStyle: para]))

if contentRange.length > 0 {
attrs.append((contentRange, [.foregroundColor: ctx.theme.mutedText]))
// theme.blockquoteText lifts the historical muting; inline
// constructs append later and keep their own ink either way.
attrs.append((contentRange, [.foregroundColor: ctx.theme.blockquoteText ?? ctx.theme.mutedText]))
}
if ctx.isActive(tokenRange) {
attrs.append((markerRange, [.foregroundColor: ctx.theme.mutedText]))
Expand Down
106 changes: 106 additions & 0 deletions Tests/MarkdownEngineTests/BlockquoteStylingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//
// BlockquoteStylingTests.swift
// MarkdownEngineTests
//
// Blockquote styling knobs: the bar/indent metrics
// (`BlockquoteStyle.barWidth` / `textIndent`) and the theme slots
// (`blockquoteBar`, `blockquoteText`). Defaults must reproduce the previous
// hard-coded constants (3pt bar, 18pt indent, muted content) exactly.
//

import AppKit
import Foundation
import Testing
@testable import MarkdownEngine

@Suite("Blockquote styling knobs")
struct BlockquoteStylingTests {

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

private func style(
_ text: String,
theme: MarkdownEditorTheme = .default,
blockquote: BlockquoteStyle = .default,
caret: Int = -1
) -> [StyledRange] {
MarkdownASTStyler.styleAttributes(
text: text, fontName: fontName, fontSize: base, caretLocation: caret,
configuration: MarkdownEditorConfiguration(theme: theme, blockquote: blockquote)
)
}

private func paragraphStyle(in attrs: [StyledRange], at pos: Int) -> NSParagraphStyle? {
var result: NSParagraphStyle?
for (range, a) in attrs where NSLocationInRange(pos, range) {
if let p = a[.paragraphStyle] as? NSParagraphStyle { result = p }
}
return result
}

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
}

// MARK: - Metrics

@Test("defaults reproduce the previous hard-coded constants")
func defaultsMatchHistoricalConstants() {
#expect(BlockquoteStyle.default.barWidth == 3)
#expect(BlockquoteStyle.default.textIndent == 18)

// Level 1 text hangs at 1 × 18 + 9 = 27pt, exactly as before.
let attrs = style("> quoted line\n")
let ps = paragraphStyle(in: attrs, at: 2)
#expect(abs((ps?.firstLineHeadIndent ?? 0) - 27) < 0.01)
#expect(abs((ps?.headIndent ?? 0) - 27) < 0.01)
}

@Test("textIndent drives the hanging indent per nesting level")
func textIndentDrivesHangingIndent() {
let narrow = BlockquoteStyle(textIndent: 12)
let single = style("> quoted line\n", blockquote: narrow)
let ps1 = paragraphStyle(in: single, at: 2)
#expect(abs((ps1?.firstLineHeadIndent ?? 0) - (12 + 6)) < 0.01)

let nested = style(">> deep quote\n", blockquote: narrow)
let ps2 = paragraphStyle(in: nested, at: 3)
#expect(abs((ps2?.firstLineHeadIndent ?? 0) - (24 + 6)) < 0.01)
}

// MARK: - Theme slots

@Test("blockquoteText lifts the historical muting; nil keeps it")
func blockquoteTextSlot() {
let text = "> quoted line\n"
let contentPos = (text as NSString).range(of: "quoted").location

let muted = style(text)
#expect(color(in: muted, at: contentPos) == MarkdownEditorTheme.default.mutedText)

let bodyInk = NSColor(calibratedWhite: 0.9, alpha: 1)
let restyled = style(text, theme: MarkdownEditorTheme(blockquoteText: bodyInk))
#expect(color(in: restyled, at: contentPos) == bodyInk)
}

@Test("the revealed > marker stays muted even with a custom content ink")
func revealedMarkerStaysMuted() {
let text = "> quoted line\n"
let bodyInk = NSColor(calibratedWhite: 0.9, alpha: 1)
// Caret on the line reveals the marker.
let attrs = style(text, theme: MarkdownEditorTheme(blockquoteText: bodyInk), caret: 3)
#expect(color(in: attrs, at: 0) == MarkdownEditorTheme.default.mutedText)
}

@Test("bar theme slot defaults to nil and carries a custom ink")
func blockquoteBarSlot() {
#expect(MarkdownEditorTheme.default.blockquoteBar == nil)
let brand = NSColor(calibratedRed: 1, green: 0.8, blue: 0.81, alpha: 1)
#expect(MarkdownEditorTheme(blockquoteBar: brand).blockquoteBar == brand)
}
}
Loading