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 bef8f019e124a547867afacdabe6c2bf6daabb09 Mon Sep 17 00:00:00 2001 From: Dylan de Heer Date: Tue, 4 Aug 2026 12:21:03 +0200 Subject: [PATCH 2/2] feat: table header background and rule theming slots Adds two additive theme slots for the rendered table image: - MarkdownEditorTheme.tableHeaderBackground fills the header row; nil (the default) keeps the historical mutedText at 8% alpha. - MarkdownEditorTheme.tableRule strokes the outer border and the internal rules; nil keeps the historical mutedText at 50% alpha. Both slots participate in the table image cache key (identity prefix and resolved-component key), so a theme differing only in a table slot renders fresh instead of reusing a stock cached image. Co-authored-by: Cursor --- CHANGELOG.md | 5 + .../Configuration/MarkdownEditorTheme.swift | 15 ++- .../Styling/MarkdownStyler+Tables.swift | 21 ++- .../TableThemingTests.swift | 120 ++++++++++++++++++ 4 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 Tests/MarkdownEngineTests/TableThemingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6c98ed..3f1225f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Table theming slots: `MarkdownEditorTheme.tableHeaderBackground` fills the + rendered header row (nil = the historical mutedText at 8% alpha) and + `MarkdownEditorTheme.tableRule` strokes the outer border and internal + rules (nil = mutedText at 50% alpha). Both participate in the table image + cache key. - 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/MarkdownEditorTheme.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift index ff87b81a..ef876328 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift @@ -88,6 +88,15 @@ public struct MarkdownEditorTheme: Sendable { /// Background color used for `==highlight==` inline markup. public var highlightColor: NSColor + // MARK: Tables + + /// Fill behind a rendered table's header row. `nil` (the default) keeps + /// the historical ``mutedText`` at 8% alpha. + public var tableHeaderBackground: NSColor? + /// Stroke color of a rendered table's outer border and internal rules. + /// `nil` (the default) keeps the historical ``mutedText`` at 50% alpha. + public var tableRule: NSColor? + // MARK: Init public init( @@ -103,7 +112,9 @@ public struct MarkdownEditorTheme: Sendable { latexLightModeText: NSColor = .black, latexDarkModeText: NSColor = .white, strikethroughColor: NSColor = .labelColor, - highlightColor: NSColor = .systemOrange.withAlphaComponent(0.4) + highlightColor: NSColor = .systemOrange.withAlphaComponent(0.4), + tableHeaderBackground: NSColor? = nil, + tableRule: NSColor? = nil ) { self.bodyText = bodyText self.mutedText = mutedText @@ -118,6 +129,8 @@ public struct MarkdownEditorTheme: Sendable { self.latexDarkModeText = latexDarkModeText self.strikethroughColor = strikethroughColor self.highlightColor = highlightColor + self.tableHeaderBackground = tableHeaderBackground + self.tableRule = tableRule } /// System-native palette built from `NSColor` dynamic system colors. diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index 21f0b734..5a9fc7d4 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -61,10 +61,14 @@ extension MarkdownStyler { private static func themeKeyPrefix(ctx: StylingContext, appearance: NSAppearance) -> String { let theme = ctx.configuration.theme + func optionalIdentity(_ color: NSColor?) -> String { + color.map { "\(ObjectIdentifier($0))" } ?? "nil" + } let identity = "\(ctx.baseFont.fontName)|\(ctx.baseFont.pointSize)|\(appearance.name.rawValue)|" + "\(ObjectIdentifier(theme.bodyText))|\(ObjectIdentifier(theme.mutedText))|" + "\(ObjectIdentifier(theme.highlightColor))|\(ObjectIdentifier(ctx.codeBackgroundColor))|" + "\(ObjectIdentifier(theme.latexLightModeText))|\(ObjectIdentifier(theme.latexDarkModeText))|" + + "\(optionalIdentity(theme.tableHeaderBackground))|\(optionalIdentity(theme.tableRule))|" + "\(ObjectIdentifier(type(of: ctx.services.latex)))" themeKeyLock.lock() @@ -87,6 +91,8 @@ extension MarkdownStyler { colorKey(ctx.codeBackgroundColor, under: appearance), colorKey(theme.latexLightModeText, under: appearance), colorKey(theme.latexDarkModeText, under: appearance), + theme.tableHeaderBackground.map { colorKey($0, under: appearance) } ?? "nil", + theme.tableRule.map { colorKey($0, under: appearance) } ?? "nil", "\(ObjectIdentifier(type(of: ctx.services.latex)))", ].joined(separator: "|") @@ -460,14 +466,17 @@ extension MarkdownStyler { let cellVPadding: CGFloat = 6 let borderWidth: CGFloat = 1 // Resolve under the real appearance: `.withAlphaComponent()` freezes a dynamic color otherwise. - func mutedColor(alpha: CGFloat) -> NSColor { - var resolved: NSColor = theme.mutedText + func resolved(_ color: NSColor) -> NSColor { + var result = color appearance.performAsCurrentDrawingAppearance { - resolved = theme.mutedText.usingColorSpace(.sRGB) ?? theme.mutedText + result = color.usingColorSpace(.sRGB) ?? color } - return resolved.withAlphaComponent(alpha) + return result + } + func mutedColor(alpha: CGFloat) -> NSColor { + resolved(theme.mutedText).withAlphaComponent(alpha) } - let borderColor = mutedColor(alpha: 0.5) + let borderColor = theme.tableRule.map(resolved) ?? mutedColor(alpha: 0.5) let baseLineHeight: CGFloat = ceil(baseFont.ascender - baseFont.descender + baseFont.leading) let minColumnContentWidth: CGFloat = 16 @@ -598,7 +607,7 @@ extension MarkdownStyler { } let alignments = table.alignments - let headerFill = mutedColor(alpha: 0.08) + let headerFill = theme.tableHeaderBackground.map(resolved) ?? mutedColor(alpha: 0.08) // Flipped image so AppKit handles the y-flip; a manual transform mirror would flip glyphs too. return NSImage(size: size, flipped: true) { _ in diff --git a/Tests/MarkdownEngineTests/TableThemingTests.swift b/Tests/MarkdownEngineTests/TableThemingTests.swift new file mode 100644 index 00000000..a930287c --- /dev/null +++ b/Tests/MarkdownEngineTests/TableThemingTests.swift @@ -0,0 +1,120 @@ +// +// TableThemingTests.swift +// MarkdownEngineTests +// +// Table theming slots: `MarkdownEditorTheme.tableHeaderBackground` and +// `MarkdownEditorTheme.tableRule`. Defaults (nil) must keep the historical +// mutedText-derived fills, and the slots must participate in the render +// cache key so themed and stock tables never share an image. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Table theming slots") +struct TableThemingTests { + + private func makeContext( + for source: String, + configuration: MarkdownEditorConfiguration = .default + ) -> MarkdownStyler.StylingContext { + let font = NSFont.systemFont(ofSize: 15) + return MarkdownStyler.StylingContext( + nsText: source as NSString, + tokens: [], + codeTokens: [], + activeTokenIndices: [], + baseFont: font, + layoutBridge: nil, + baseDefaultLineHeight: 18, + codeBackgroundColor: .windowBackgroundColor, + latexMarkerFont: font, + configuration: configuration, + wikiLinkIDProvider: { _ in nil } + ) + } + + private func sample(_ image: NSImage, x: Int, y: Int) -> NSColor? { + let size = image.size + guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .calibratedRGB, bytesPerRow: 0, bitsPerPixel: 0 + ) else { return nil } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap) + image.draw(in: NSRect(origin: .zero, size: size)) + NSGraphicsContext.restoreGraphicsState() + // NSImage draws bottom-up; convert the top-down y used by the table + // renderer into the bitmap's coordinate space. + return bitmap.colorAt(x: x, y: y) + } + + @Test("slots default to nil") + func slotsDefaultToNil() { + #expect(MarkdownEditorTheme.default.tableHeaderBackground == nil) + #expect(MarkdownEditorTheme.default.tableRule == nil) + } + + @Test("custom header fill and rule ink reach the rendered bitmap") + func customColorsReachTheBitmap() throws { + let source = "| head | col |\n|---|---|\n| a | b |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let aqua = try #require(NSAppearance(named: .aqua)) + + var themed = MarkdownEditorConfiguration.default + themed.theme.tableHeaderBackground = .red + themed.theme.tableRule = .blue + + let (image, _) = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: themed), + appearance: aqua, availableWidth: 2000 + ) + + // Top-left interior of the header row (inside the 1pt border, away + // from any glyph) carries the header fill. + let headerPixel = try #require(sample(image, x: 3, y: 3)) + #expect(headerPixel.redComponent > 0.9) + #expect(headerPixel.blueComponent < 0.3) + + // The outer border column carries the rule ink. + let rulePixel = try #require(sample(image, x: 0, y: Int(image.size.height / 2))) + #expect(rulePixel.blueComponent > 0.9) + #expect(rulePixel.redComponent < 0.3) + } + + // The cache key must cover the new slots — a theme differing only in a + // table slot must be a miss, never the stock cached image. + @Test("changing a table slot renders fresh instead of reusing the cache") + func tableSlotChangeRendersFresh() throws { + let source = "| iota | kappa |\n|---|---|\n| 11 | 12 |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let aqua = try #require(NSAppearance(named: .aqua)) + + _ = MarkdownStyler.tableImage( + for: source, parsed: parsed, ctx: makeContext(for: source), + appearance: aqua, availableWidth: 2000 + ) + + var ruled = MarkdownEditorConfiguration.default + ruled.theme.tableRule = .systemPink + let repainted = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: ruled), + appearance: aqua, availableWidth: 2000 + ) + #expect(repainted.rendered) + + var filled = MarkdownEditorConfiguration.default + filled.theme.tableHeaderBackground = .systemTeal + let refilled = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: filled), + appearance: aqua, availableWidth: 2000 + ) + #expect(refilled.rendered) + } +}