From 4c5ee23e4fda77fd895c7acd0a3eb19d6ad2a07f Mon Sep 17 00:00:00 2001 From: Dylan de Heer Date: Mon, 3 Aug 2026 22:59:26 +0200 Subject: [PATCH 1/2] Add optional heading typeface and heading text color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theme can recolor every element except heading text (it inherits bodyText), and the heading config covers every metric except the face — so an embedder that wants serif display headings over a sans body has to fork. HeadingStyle.fontName picks a PostScript face for headings, honored exactly so the chosen weight survives; an unresolvable name falls back to the stock bold base font, like TaskCheckboxStyle's symbol fallback. MarkdownEditorTheme.headingText colors heading text while the `#` glyphs stay on headingMarker and inline constructs inside a heading keep their own ink. Emphasis inside a heading still composes on top of the configured face (traits added, family and per-level size kept). Both knobs default to nil, which keeps styling identical to before. Co-authored-by: Cursor --- CHANGELOG.md | 9 + .../MarkdownEditorConfiguration.swift | 15 ++ .../Configuration/MarkdownEditorTheme.swift | 11 ++ .../Styling/MarkdownASTStyler.swift | 22 ++- .../HeadingFontAndColorTests.swift | 179 ++++++++++++++++++ 5 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 Tests/MarkdownEngineTests/HeadingFontAndColorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index dc542980..0d6c98ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- 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 diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 871d144c..983af71b 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -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 } diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift index cc06a7f2..ff87b81a 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift @@ -36,6 +36,15 @@ 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 @@ -85,6 +94,7 @@ public struct MarkdownEditorTheme: Sendable { bodyText: NSColor = .labelColor, mutedText: NSColor = .secondaryLabelColor, disabledText: NSColor = .tertiaryLabelColor, + headingText: NSColor? = nil, headingMarker: NSColor = .gray, link: NSColor = .linkColor, incompleteLink: NSColor = .systemBlue, @@ -98,6 +108,7 @@ public struct MarkdownEditorTheme: Sendable { self.bodyText = bodyText self.mutedText = mutedText self.disabledText = disabledText + self.headingText = headingText self.headingMarker = headingMarker self.link = link self.incompleteLink = incompleteLink diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift index e76cacb2..22a98a86 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift @@ -548,9 +548,15 @@ 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 @@ -558,7 +564,15 @@ enum MarkdownASTStyler { 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])) } diff --git a/Tests/MarkdownEngineTests/HeadingFontAndColorTests.swift b/Tests/MarkdownEngineTests/HeadingFontAndColorTests.swift new file mode 100644 index 00000000..bb8a8acb --- /dev/null +++ b/Tests/MarkdownEngineTests/HeadingFontAndColorTests.swift @@ -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)) + } + } +} From 6e788f4fa6a2e2156a6aab7d6d7377fa86e3413a Mon Sep 17 00:00:00 2001 From: Dylan de Heer Date: Tue, 4 Aug 2026 12:13:46 +0200 Subject: [PATCH 2/2] feat: configurable blockquote bar, indent, and content colors Adds additive blockquote styling knobs so hosts can align quotes with their design system without changing the defaults: - BlockquoteStyle.barWidth / textIndent expose the previously hard-coded 3pt bar width and 18pt per-level indent. The styler's hanging indent and the painted bars read the same values, so they stay in lockstep at every nesting level. - MarkdownEditorTheme.blockquoteBar colors the painted bar; nil keeps the historical half-transparent mutedText fill. - MarkdownEditorTheme.blockquoteText lifts the historical content muting; nil keeps mutedText. Revealed `>` markers stay muted and inline constructs keep their own colors either way. All defaults reproduce the previous rendering exactly. Co-authored-by: Cursor --- CHANGELOG.md | 6 + .../MarkdownEditorConfiguration.swift | 18 ++- .../Configuration/MarkdownEditorTheme.swift | 16 +++ .../Renderer/MarkdownTextLayoutFragment.swift | 18 ++- .../Styling/MarkdownASTStyler.swift | 6 +- .../BlockquoteStylingTests.swift | 106 ++++++++++++++++++ 6 files changed, 156 insertions(+), 14 deletions(-) create mode 100644 Tests/MarkdownEngineTests/BlockquoteStylingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6c98ed..aa45080d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ 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), diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 983af71b..f9ff0a83 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -460,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 @@ -469,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() diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift index ff87b81a..4d292246 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift @@ -48,6 +48,18 @@ public struct MarkdownEditorTheme: Sendable { /// 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. @@ -96,6 +108,8 @@ public struct MarkdownEditorTheme: Sendable { disabledText: NSColor = .tertiaryLabelColor, headingText: NSColor? = nil, headingMarker: NSColor = .gray, + blockquoteBar: NSColor? = nil, + blockquoteText: NSColor? = nil, link: NSColor = .linkColor, incompleteLink: NSColor = .systemBlue, findMatchHighlight: NSColor = .systemYellow, @@ -110,6 +124,8 @@ public struct MarkdownEditorTheme: Sendable { self.disabledText = disabledText self.headingText = headingText self.headingMarker = headingMarker + self.blockquoteBar = blockquoteBar + self.blockquoteText = blockquoteText self.link = link self.incompleteLink = incompleteLink self.findMatchHighlight = findMatchHighlight diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index 9c0c5ea7..81a0f4a2 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -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 @@ -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 diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift index 22a98a86..c725cdb1 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift @@ -631,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 { @@ -674,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])) diff --git a/Tests/MarkdownEngineTests/BlockquoteStylingTests.swift b/Tests/MarkdownEngineTests/BlockquoteStylingTests.swift new file mode 100644 index 00000000..f059eef2 --- /dev/null +++ b/Tests/MarkdownEngineTests/BlockquoteStylingTests.swift @@ -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) + } +}