diff --git a/CHANGELOG.md b/CHANGELOG.md index dc542980..1ecae36e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Code-block CARD rendering (opt-in): `CodeBlockStyle.cornerRadius` draws a + fenced block as one continuous rounded card spanning the whole block — + wrapped lines included — instead of square per-paragraph fills, and + `CodeBlockStyle.cardVerticalPadding` pins the hidden fence lines' height so + they read as the card's interior padding (fences revert to their natural + height while the caret reveals them). Selection stays visible above the + card. `nil` (the default) keeps the historical rendering exactly. +- Inline-code CHIP rendering (opt-in): `InlineCodeStyle.chipCornerRadius` + draws a small rounded background hugging each inline `code` span (per + wrapped line) with `chipHorizontalPadding` of breathing room, replacing the + square glyph-run fill. `MarkdownEditorTheme.inlineCodeBackground` colors the + chip (nil = `codeBackground`, then the syntax-highlighter background). +- Code typography and background knobs: `CodeBlockStyle.fontName` and + `InlineCodeStyle.fontName` swap the code face (nil = the + syntax-highlighter service's font, as before; inline follows the block + face unless set independently), and `MarkdownEditorTheme.codeBackground` + replaces the background behind fenced blocks and inline spans (nil = the + service's background, as before). +- 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..5c7e1b57 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -249,21 +249,46 @@ public struct MarkerStyle: Sendable { /// Styling for fenced code blocks (```language ... ```). public struct CodeBlockStyle: Sendable { + /// PostScript name of the typeface used for code-block text, for example + /// `"GeistMono-Regular"`. `nil` (the default) keeps the historical + /// behavior: the syntax-highlighter service's code font. A name that + /// doesn't resolve degrades to that service font as well. + public var fontName: String? /// Code-block font size as a fraction of the document base font size. public var fontSizeScale: CGFloat /// Vertical paragraph spacing applied above and below the code block. public var paragraphSpacing: CGFloat /// Left/right indent (in points) so code blocks don't run into the gutter. public var horizontalIndent: CGFloat + /// Corner radius of the code-block CARD. Setting it opts the block into + /// card rendering: one continuous rounded background spanning the whole + /// fenced block (wrapped lines included), with the corners rounded on the + /// block's first and last lines only. `nil` (the default) keeps the + /// historical rendering — a square per-paragraph fill from the + /// `.backgroundColor` attribute. + public var cornerRadius: CGFloat? + /// Interior vertical padding of the card: the hidden fence lines' pinned + /// line height, i.e. the space between the card's edge and the first/last + /// code line. Only takes effect in card mode while the fences are hidden; + /// while the caret reveals the fences they keep their natural code line + /// height so editing them stays comfortable. `nil` (the default) keeps + /// the fences' natural height. + public var cardVerticalPadding: CGFloat? public init( + fontName: String? = nil, fontSizeScale: CGFloat = 0.85, paragraphSpacing: CGFloat = 2.0, - horizontalIndent: CGFloat = 12.0 + horizontalIndent: CGFloat = 12.0, + cornerRadius: CGFloat? = nil, + cardVerticalPadding: CGFloat? = nil ) { + self.fontName = fontName self.fontSizeScale = fontSizeScale self.paragraphSpacing = paragraphSpacing self.horizontalIndent = horizontalIndent + self.cornerRadius = cornerRadius + self.cardVerticalPadding = cardVerticalPadding } public static let `default` = CodeBlockStyle() @@ -275,9 +300,32 @@ public struct CodeBlockStyle: Sendable { public struct InlineCodeStyle: Sendable { /// Inline-code reuses the code block font size scale by default. public var fontSizeScale: CGFloat + /// PostScript name of the typeface used for inline-code text. `nil` + /// (the default) keeps the historical behavior: inline code renders in + /// the code-block font. A name that doesn't resolve degrades the same + /// way. + public var fontName: String? + /// Corner radius of the inline-code CHIP. Setting it opts inline `code` + /// spans into chip rendering: a small rounded background drawn behind the + /// span (per wrapped line) with `chipHorizontalPadding` of breathing room + /// on each side, hugging the code text's own height instead of the whole + /// line box. `nil` (the default) keeps the historical rendering — a + /// square glyph-run fill from the `.backgroundColor` attribute. + public var chipCornerRadius: CGFloat? + /// Horizontal padding (in points) the chip extends beyond the span's + /// first and last glyph. Only read in chip mode. + public var chipHorizontalPadding: CGFloat - public init(fontSizeScale: CGFloat = 0.85) { + public init( + fontSizeScale: CGFloat = 0.85, + fontName: String? = nil, + chipCornerRadius: CGFloat? = nil, + chipHorizontalPadding: CGFloat = 3.0 + ) { self.fontSizeScale = fontSizeScale + self.fontName = fontName + self.chipCornerRadius = chipCornerRadius + self.chipHorizontalPadding = chipHorizontalPadding } public static let `default` = InlineCodeStyle() @@ -349,15 +397,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..c1fdfb7e 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 @@ -79,12 +88,26 @@ public struct MarkdownEditorTheme: Sendable { /// Background color used for `==highlight==` inline markup. public var highlightColor: NSColor + // MARK: Code + + /// Background color behind fenced code blocks and inline `` `code` `` + /// spans. `nil` (the default) keeps the historical behavior: the + /// syntax-highlighter service's background color. + public var codeBackground: NSColor? + + /// Background color of the inline-code CHIP + /// (``InlineCodeStyle/chipCornerRadius`` set). `nil` (the default) falls + /// back to ``codeBackground`` and then to the syntax-highlighter + /// service's background color. + public var inlineCodeBackground: NSColor? + // MARK: Init public init( bodyText: NSColor = .labelColor, mutedText: NSColor = .secondaryLabelColor, disabledText: NSColor = .tertiaryLabelColor, + headingText: NSColor? = nil, headingMarker: NSColor = .gray, link: NSColor = .linkColor, incompleteLink: NSColor = .systemBlue, @@ -93,11 +116,14 @@ 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), + codeBackground: NSColor? = nil, + inlineCodeBackground: NSColor? = nil ) { self.bodyText = bodyText self.mutedText = mutedText self.disabledText = disabledText + self.headingText = headingText self.headingMarker = headingMarker self.link = link self.incompleteLink = incompleteLink @@ -107,6 +133,8 @@ public struct MarkdownEditorTheme: Sendable { self.latexDarkModeText = latexDarkModeText self.strikethroughColor = strikethroughColor self.highlightColor = highlightColor + self.codeBackground = codeBackground + self.inlineCodeBackground = inlineCodeBackground } /// System-native palette built from `NSColor` dynamic system colors. diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index 9c0c5ea7..cb9f8ed6 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -33,6 +33,14 @@ extension NSAttributedString.Key { static let scrollableBlockTotalHeight = NSAttributedString.Key("ScrollableBlockTotalHeight") /// NSValue(range:) — full multi-line range of the wide-table source, used to scope width-change restyles. static let scrollableBlockFullRange = NSAttributedString.Key("ScrollableBlockFullRange") + /// NSValue(range:) — whole fenced-block range (fences included). Marks the + /// block as card-rendered (`CodeBlockStyle.cornerRadius` set); each + /// fragment uses the range to decide whether it draws the card's top + /// and/or bottom rounded corners. + static let codeBlockCard = NSAttributedString.Key("CodeBlockCard") + /// Marks an inline `code` span rendered as a rounded chip + /// (`InlineCodeStyle.chipCornerRadius` set). Set to `true`. + static let inlineCodeChip = NSAttributedString.Key("InlineCodeChip") } final class MarkdownTextLayoutFragment: NSTextLayoutFragment { @@ -59,7 +67,7 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { var bounds = super.renderingSurfaceBounds // Task checkboxes too: the box draws left of the first glyph (marker // slot), outside the default text surface — TextKit would clip it. - if hasCodeBlockBackground || hasThematicBreak || hasBlockquote || hasTaskCheckbox { + if hasCodeBlockBackground || hasThematicBreak || hasBlockquote || hasTaskCheckbox || hasInlineCodeChip { let containerWidth = textLayoutManager?.textContainer?.size.width ?? bounds.width // Extend left to container edge bounds.origin.x = -layoutFragmentFrame.origin.x @@ -79,6 +87,9 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { // 1. Code-block backgrounds (behind text) drawCodeBlockBackground(at: point, in: context) + // 1b. Inline-code chips (behind text; selection cut out like 1) + drawInlineCodeChips(at: point, in: context) + // 2. LaTeX images (behind text — hidden markers are invisible anyway) drawLatexImages(at: point, in: context) @@ -160,11 +171,24 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { private var hasCodeBlockBackground: Bool { guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return false } + if ts.attribute(.codeBlockCard, at: range.location, effectiveRange: nil) != nil { return true } let bgColor = ts.attribute(.backgroundColor, at: range.location, effectiveRange: nil) as? NSColor guard let bgColor else { return false } return isCodeBlockBackgroundColor(bgColor) } + private var hasInlineCodeChip: Bool { + guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return false } + var found = false + ts.enumerateAttribute(.inlineCodeChip, in: range, options: []) { value, _, stop in + if value as? Bool == true { + found = true + stop.pointee = true + } + } + return found + } + private var hasThematicBreak: Bool { guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return false } var found = false @@ -204,6 +228,14 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { private func drawCodeBlockBackground(at point: CGPoint, in context: CGContext) { guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return } + // Card mode: the styler tags the block with `.codeBlockCard` instead + // of a `.backgroundColor`, and each fragment paints its slice of one + // continuous rounded card. + if let cardValue = ts.attribute(.codeBlockCard, at: range.location, effectiveRange: nil) as? NSValue { + drawCodeBlockCardSlice(blockRange: cardValue.rangeValue, at: point, in: context) + return + } + // Only fenced code-block fragments get the full-width fill (first char must carry the code background). guard let color = ts.attribute(.backgroundColor, at: range.location, effectiveRange: nil) as? NSColor, isCodeBlockBackgroundColor(color) else { return } @@ -253,6 +285,161 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { } } + /// One fragment's slice of the code-block CARD: a full-width fill whose + /// corners round only on the fragments holding the block's first and last + /// characters, so the whole fenced block (wrapped lines included) reads + /// as ONE continuous rounded card. Rounding is achieved by extending the + /// rounded rect past the slice on the sides that must stay square and + /// clipping back to the slice — every fragment still draws strictly + /// inside its own strip, so invalidation and scrolling stay per-fragment. + private func drawCodeBlockCardSlice(blockRange: NSRange, at point: CGPoint, in context: CGContext) { + guard let range = fragmentNSRange else { return } + let configuration = (textLayoutManager?.textContainer?.textView as? NativeTextView)? + .configuration ?? .default + guard let radius = configuration.codeBlock.cornerRadius else { return } + let color = configuration.theme.codeBackground + ?? configuration.services.syntaxHighlighter.backgroundColor() + + let containerWidth = textLayoutManager?.textContainer?.size.width ?? layoutFragmentFrame.width + + var effectiveHeight = layoutFragmentFrame.height + if textLineFragments.count > 1, + let lastLF = textLineFragments.last, + lastLF.characterRange.length == 0 { + effectiveHeight -= lastLF.typographicBounds.height + } + + let scale = textLayoutManager?.textContainer?.textView?.window?.backingScaleFactor + ?? NSScreen.main?.backingScaleFactor ?? 2.0 + let snappedY = floor(point.y * scale) / scale + let snappedMaxY = ceil((point.y + effectiveHeight) * scale) / scale + + let bgRect = CGRect( + x: point.x - layoutFragmentFrame.origin.x, + y: snappedY, + width: containerWidth, + height: snappedMaxY - snappedY + ) + + let isTop = NSLocationInRange(blockRange.location, range) + let isBottom = blockRange.length > 0 && NSLocationInRange(NSMaxRange(blockRange) - 1, range) + var cardRect = bgRect + if !isTop { + cardRect.origin.y -= radius + cardRect.size.height += radius + } + if !isBottom { + cardRect.size.height += radius + } + + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true) + + NSBezierPath(rect: bgRect).setClip() + let path = NSBezierPath(roundedRect: cardRect, xRadius: radius, yRadius: radius) + path.windingRule = .evenOdd + // Selection stays visible inside the card, same as the legacy fill. + for r in selectionRectsInDrawCoordinates(drawPoint: point, snappedY: snappedY, snappedMaxY: snappedMaxY) { + let cut = r.intersection(bgRect) + if !cut.isEmpty { path.appendRect(cut) } + } + color.setFill() + path.fill() + } + + // MARK: - Inline Code Chips + + /// Rounded chip behind each inline `code` span (`.inlineCodeChip`): a + /// small background hugging the code text's own height with a little + /// horizontal breathing room, drawn per wrapped line. Selection is cut + /// out even-odd so the system highlight stays visible above the chip. + private func drawInlineCodeChips(at point: CGPoint, in context: CGContext) { + guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return } + guard let textView = textLayoutManager?.textContainer?.textView as? NativeTextView else { return } + let configuration = textView.configuration + guard let radius = configuration.inlineCode.chipCornerRadius else { return } + + var chipRects: [CGRect] = [] + let pad = configuration.inlineCode.chipHorizontalPadding + ts.enumerateAttribute(.inlineCodeChip, in: range, options: []) { [weak self] value, attrRange, _ in + guard let self, (value as? Bool) == true else { return } + let font = (ts.attribute(.font, at: attrRange.location, effectiveRange: nil) as? NSFont) + ?? textView.baseFont + let ascent = max(0, font.ascender) + let descent = max(0, -font.descender) + for lineFragment in self.textLineFragments { + let lr = lineFragment.characterRange + let lineDocRange = NSRange(location: range.location + lr.location, length: lr.length) + let inter = NSIntersectionRange(lineDocRange, attrRange) + guard inter.length > 0 else { continue } + let tb = lineFragment.typographicBounds + let startPos = lineFragment.locationForCharacter(at: inter.location - range.location) + let endPos = lineFragment.locationForCharacter(at: NSMaxRange(inter) - range.location) + let x0 = point.x + tb.origin.x + startPos.x + let x1 = point.x + tb.origin.x + endPos.x + guard x1 > x0 else { continue } + let baselineY = point.y + tb.origin.y + startPos.y + chipRects.append(CGRect( + x: x0 - pad, + y: baselineY - ascent - 1, + width: (x1 - x0) + pad * 2, + height: ascent + descent + 2 + )) + } + } + guard !chipRects.isEmpty else { return } + + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true) + + let selectionRects = selectionSegmentRects(drawPoint: point) + let color = configuration.theme.inlineCodeBackground + ?? configuration.theme.codeBackground + ?? configuration.services.syntaxHighlighter.backgroundColor() + color.setFill() + let path = NSBezierPath() + path.windingRule = .evenOdd + for chip in chipRects { + path.appendRoundedRect(chip, xRadius: radius, yRadius: radius) + for r in selectionRects { + let cut = r.intersection(chip) + if !cut.isEmpty { path.appendRect(cut) } + } + } + path.fill() + } + + /// Active text-selection segment rectangles intersecting this fragment, + /// in draw-relative coordinates, with their natural segment heights + /// (unlike `selectionRectsInDrawCoordinates`, which expands to a snapped + /// vertical span for the code-block fill's cut-out). + private func selectionSegmentRects(drawPoint: CGPoint) -> [CGRect] { + guard let tlm = textLayoutManager else { return [] } + var rects: [CGRect] = [] + let dx = drawPoint.x - layoutFragmentFrame.origin.x + let dy = drawPoint.y - layoutFragmentFrame.origin.y + let myRange = self.rangeInElement + + for selection in tlm.textSelections { + for textRange in selection.textRanges { + let interStart = textRange.location.compare(myRange.location) == .orderedAscending + ? myRange.location : textRange.location + let interEnd = textRange.endLocation.compare(myRange.endLocation) == .orderedDescending + ? myRange.endLocation : textRange.endLocation + guard interStart.compare(interEnd) == .orderedAscending, + let intersection = NSTextRange(location: interStart, end: interEnd) else { continue } + + tlm.enumerateTextSegments(in: intersection, type: .selection, options: []) { _, segFrame, _, _ in + rects.append(segFrame.offsetBy(dx: dx, dy: dy)) + return true + } + } + } + return rects + } + /// Returns active text-selection rectangles intersecting this fragment, in /// the same draw-relative coordinate system used by `drawCodeBlockBackground`. private func selectionRectsInDrawCoordinates(drawPoint: CGPoint, snappedY: CGFloat, snappedMaxY: CGFloat) -> [CGRect] { diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift index e76cacb2..798c9ed4 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift @@ -39,7 +39,15 @@ enum MarkdownASTStyler { let codeFontSize = round(fontSize * configuration.codeBlock.fontSizeScale) let hiddenSize = configuration.markers.hiddenMarkerFontSize let ns = text as NSString - let codeFont = configuration.services.syntaxHighlighter.codeFont(size: codeFontSize) + // A configured code face is honored exactly; a name that doesn't + // resolve degrades to the syntax-highlighter service's font, so a + // typo changes nothing (mirrors HeadingStyle.fontName's fallback). + let codeFont = configuration.codeBlock.fontName + .flatMap { NSFont(name: $0, size: codeFontSize) } + ?? configuration.services.syntaxHighlighter.codeFont(size: codeFontSize) + let inlineCodeFont = configuration.inlineCode.fontName + .flatMap { NSFont(name: $0, size: codeFontSize) } + ?? codeFont let codeLineHeight = ceil(codeFont.ascender - codeFont.descender + codeFont.leading) let codePara = NSMutableParagraphStyle() codePara.lineBreakMode = .byCharWrapping @@ -62,7 +70,9 @@ enum MarkdownASTStyler { baseLineHeight: baseLineHeight, baseParagraphSpacing: baseParagraphSpacing, codeFont: codeFont, - codeBackground: configuration.services.syntaxHighlighter.backgroundColor(), + inlineCodeFont: inlineCodeFont, + codeBackground: configuration.theme.codeBackground + ?? configuration.services.syntaxHighlighter.backgroundColor(), codeParagraphStyle: codePara, inlineMarkerFont: NSFont(name: fontName, size: hiddenSize) ?? .systemFont(ofSize: hiddenSize), caret: caretLocation, @@ -496,6 +506,7 @@ enum MarkdownASTStyler { let baseLineHeight: CGFloat let baseParagraphSpacing: CGFloat let codeFont: NSFont + let inlineCodeFont: NSFont let codeBackground: NSColor let codeParagraphStyle: NSParagraphStyle let inlineMarkerFont: NSFont @@ -548,9 +559,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 +575,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])) } @@ -674,9 +699,23 @@ enum MarkdownASTStyler { private static func styleCodeBlock(range: NSRange, ctx: Ctx, into attrs: inout [StyledRange]) { let parts = codeBlockParts(range, ctx.ns) - attrs.append((parts.codeRange, [ - .font: ctx.codeFont, .backgroundColor: ctx.codeBackground, .paragraphStyle: ctx.codeParagraphStyle, - ])) + let cardMode = ctx.config.codeBlock.cornerRadius != nil + if cardMode { + // Card mode (CodeBlockStyle.cornerRadius): the fragment paints ONE + // continuous rounded card over the whole block, keyed off + // `.codeBlockCard` (which carries the block range so each + // fragment knows whether it holds the block's first/last line). + // No `.backgroundColor` — the square glyph-run fill would sit on + // top of the card. + attrs.append((parts.codeRange, [ + .font: ctx.codeFont, .paragraphStyle: ctx.codeParagraphStyle, + .codeBlockCard: NSValue(range: parts.codeRange), + ])) + } else { + attrs.append((parts.codeRange, [ + .font: ctx.codeFont, .backgroundColor: ctx.codeBackground, .paragraphStyle: ctx.codeParagraphStyle, + ])) + } // Suppress spell-check underlines on the whole fenced block — code is not prose. attrs.append((parts.codeRange, [.spellingState: 0])) let codeContent = ctx.ns.substring(with: parts.content) @@ -693,6 +732,20 @@ enum MarkdownASTStyler { : [.foregroundColor: NSColor.clear, .font: ctx.codeFont] // hiddenMarkerFont == codeFont attrs.append((parts.openFence, markerAttrs)) attrs.append((parts.closeFence, markerAttrs)) + + // Card interior padding: pin the HIDDEN fence lines' height so the + // fence rows read as the card's vertical padding above the first and + // below the last code line. While the caret reveals the fences they + // keep the natural code line height for comfortable editing. + if cardMode, let pad = ctx.config.codeBlock.cardVerticalPadding, !ctx.isActive(range) { + guard let fencePara = ctx.codeParagraphStyle.mutableCopy() as? NSMutableParagraphStyle else { return } + fencePara.minimumLineHeight = pad + fencePara.maximumLineHeight = pad + attrs.append((ctx.ns.paragraphRange(for: parts.openFence), [.paragraphStyle: fencePara])) + if parts.closeFence.length > 0 { + attrs.append((ctx.ns.paragraphRange(for: parts.closeFence), [.paragraphStyle: fencePara])) + } + } } /// Split a fenced-code range into open fence (+language), content, close fence, and language. @@ -750,11 +803,19 @@ enum MarkdownASTStyler { styleInlines(node.children, font: font, ctx: ctx, into: &attrs) case .code(let range, let contentRange): - attrs.append((contentRange, [.font: ctx.codeFont, .backgroundColor: ctx.codeBackground])) + if ctx.config.inlineCode.chipCornerRadius != nil { + // Chip mode (InlineCodeStyle.chipCornerRadius): the + // fragment paints a rounded chip hugging the span; the + // square glyph-run fill would fight it, so no + // `.backgroundColor` here. + attrs.append((contentRange, [.font: ctx.inlineCodeFont, .inlineCodeChip: true])) + } else { + attrs.append((contentRange, [.font: ctx.inlineCodeFont, .backgroundColor: ctx.codeBackground])) + } // Suppress spell-check underlines on inline `code` spans (markers + content). attrs.append((range, [.spellingState: 0])) let markerAttrs: [NSAttributedString.Key: Any] = ctx.isActive(range) - ? [.foregroundColor: ctx.theme.mutedText, .font: ctx.codeFont] + ? [.foregroundColor: ctx.theme.mutedText, .font: ctx.inlineCodeFont] : [.foregroundColor: ctx.theme.mutedText.withAlphaComponent(ctx.config.markers.inlineCodeMarkerAlpha), .font: ctx.inlineMarkerFont] for marker in markers(of: range, content: contentRange) { attrs.append((marker, markerAttrs)) } diff --git a/Tests/MarkdownEngineTests/CodeBlockCardTests.swift b/Tests/MarkdownEngineTests/CodeBlockCardTests.swift new file mode 100644 index 00000000..96caba98 --- /dev/null +++ b/Tests/MarkdownEngineTests/CodeBlockCardTests.swift @@ -0,0 +1,143 @@ +// +// CodeBlockCardTests.swift +// MarkdownEngineTests +// +// Card rendering for fenced code blocks (`CodeBlockStyle.cornerRadius` + +// `cardVerticalPadding`) and chip rendering for inline code spans +// (`InlineCodeStyle.chipCornerRadius`). Both are opt-in; the defaults must +// keep the historical `.backgroundColor` glyph-run fills exactly. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Code block card and inline code chips") +struct CodeBlockCardTests { + + private let base: CGFloat = 16 + private var fontName: String { NSFont.systemFont(ofSize: 16).fontName } + + private func style(_ text: String, _ config: MarkdownEditorConfiguration = .default, + caret: Int = -1) -> [StyledRange] { + MarkdownASTStyler.styleAttributes( + text: text, fontName: fontName, fontSize: base, + caretLocation: caret, configuration: config + ) + } + + private var cardConfig: MarkdownEditorConfiguration { + MarkdownEditorConfiguration( + codeBlock: CodeBlockStyle(cornerRadius: 8, cardVerticalPadding: 16) + ) + } + + private var chipConfig: MarkdownEditorConfiguration { + MarkdownEditorConfiguration( + inlineCode: InlineCodeStyle(chipCornerRadius: 4, chipHorizontalPadding: 3) + ) + } + + private let fenced = "```swift\nlet x = 1\n```\n" + + // MARK: - Defaults preserved + + @Test("nil cornerRadius keeps the historical background attribute and no card tag") + func defaultKeepsBackgroundColor() { + let attrs = style(fenced) + let codePos = (fenced as NSString).range(of: "let x").location + let hasBackground = attrs.contains { range, a in + NSLocationInRange(codePos, range) && a[.backgroundColor] != nil + } + let hasCard = attrs.contains { _, a in a[.codeBlockCard] != nil } + #expect(hasBackground) + #expect(!hasCard) + } + + @Test("nil chipCornerRadius keeps the historical inline-code background and no chip tag") + func defaultKeepsInlineBackground() { + let text = "some `code` here\n" + let attrs = style(text) + let codePos = (text as NSString).range(of: "code").location + let hasBackground = attrs.contains { range, a in + NSLocationInRange(codePos, range) && a[.backgroundColor] != nil + } + let hasChip = attrs.contains { _, a in a[.inlineCodeChip] != nil } + #expect(hasBackground) + #expect(!hasChip) + } + + // MARK: - Card mode + + @Test("card mode tags the whole block and drops the square background fill") + func cardModeTagsBlock() { + let attrs = style(fenced, cardConfig) + let ns = fenced as NSString + let codePos = ns.range(of: "let x").location + + var cardRange: NSRange? + for (range, a) in attrs where a[.codeBlockCard] != nil { + cardRange = (a[.codeBlockCard] as? NSValue)?.rangeValue + #expect(NSLocationInRange(codePos, range)) + } + // The tag carries the block range: opening fence through closing fence. + #expect(cardRange?.location == 0) + #expect(cardRange.map { NSMaxRange($0) } == ns.range(of: "```", options: .backwards).length + ns.range(of: "```", options: .backwards).location) + + let hasBackground = attrs.contains { range, a in + NSLocationInRange(codePos, range) && a[.backgroundColor] != nil + } + #expect(!hasBackground) + } + + @Test("card padding pins the hidden fence lines' height and lifts while the caret reveals them") + func cardPaddingPinsFenceLines() { + let ns = fenced as NSString + + // Effective paragraph style on the opening fence (last range wins). + func fenceStyle(_ attrs: [StyledRange]) -> NSParagraphStyle? { + var result: NSParagraphStyle? + for (range, a) in attrs where NSLocationInRange(0, range) { + if let p = a[.paragraphStyle] as? NSParagraphStyle { result = p } + } + return result + } + + // Hidden fences: pinned to the padding. + let hidden = fenceStyle(style(fenced, cardConfig)) + #expect(hidden?.minimumLineHeight == 16) + #expect(hidden?.maximumLineHeight == 16) + + // Caret inside the block: fences reveal at their natural code height. + let active = fenceStyle(style(fenced, cardConfig, caret: ns.range(of: "let x").location)) + #expect(active != nil) + #expect(active?.minimumLineHeight != 16) + } + + // MARK: - Chip mode + + @Test("chip mode tags the span content and drops the square background fill") + func chipModeTagsSpan() { + let text = "some `code` here\n" + let attrs = style(text, chipConfig) + let ns = text as NSString + let contentRange = ns.range(of: "code") + + let chip = attrs.first { _, a in (a[.inlineCodeChip] as? Bool) == true } + #expect(chip?.0 == contentRange) + let hasBackground = attrs.contains { range, a in + NSLocationInRange(contentRange.location, range) && a[.backgroundColor] != nil + } + #expect(!hasBackground) + } + + // MARK: - Theme slot + + @Test("inlineCodeBackground defaults to nil and is carried by the theme") + func inlineCodeBackgroundSlot() { + #expect(MarkdownEditorTheme.default.inlineCodeBackground == nil) + let themed = MarkdownEditorTheme(inlineCodeBackground: .systemTeal) + #expect(themed.inlineCodeBackground == .systemTeal) + } +} diff --git a/Tests/MarkdownEngineTests/CodeFontThemingTests.swift b/Tests/MarkdownEngineTests/CodeFontThemingTests.swift new file mode 100644 index 00000000..5d3c1582 --- /dev/null +++ b/Tests/MarkdownEngineTests/CodeFontThemingTests.swift @@ -0,0 +1,131 @@ +// +// CodeFontThemingTests.swift +// MarkdownEngineTests +// +// Code typography and background knobs: `CodeBlockStyle.fontName`, +// `InlineCodeStyle.fontName`, and the `MarkdownEditorTheme.codeBackground` +// slot. Defaults must keep the syntax-highlighter service's font and +// background exactly as before. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Code font and background knobs") +struct CodeFontThemingTests { + + private let base: CGFloat = 16 + private var fontName: String { NSFont.systemFont(ofSize: 16).fontName } + /// A face that ships with macOS and is not the mono default. + private let customFace = "Menlo-Regular" + + private func style( + _ text: String, configuration: MarkdownEditorConfiguration = .default + ) -> [StyledRange] { + MarkdownASTStyler.styleAttributes( + text: text, fontName: fontName, fontSize: base, configuration: configuration + ) + } + + 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 + } + + private func background(in attrs: [StyledRange], at pos: Int) -> NSColor? { + var result: NSColor? + for (range, a) in attrs where NSLocationInRange(pos, range) { + if let c = a[.backgroundColor] as? NSColor { result = c } + } + return result + } + + // MARK: - Fonts + + @Test("nil fontName keeps the service's code font in blocks and inline") + func defaultsKeepServiceFont() { + let expected = MarkdownEditorServices.default.syntaxHighlighter + .codeFont(size: round(base * 0.85)) + + let block = style("```\nlet x = 1\n```\n") + #expect(font(in: block, at: 6)?.fontName == expected.fontName) + + let text = "some `code` here\n" + let inline = style(text) + let pos = (text as NSString).range(of: "code").location + #expect(font(in: inline, at: pos)?.fontName == expected.fontName) + } + + @Test("codeBlock.fontName swaps the block face; inline follows by default") + func codeBlockFontNameAppliesToBoth() { + let config = MarkdownEditorConfiguration(codeBlock: CodeBlockStyle(fontName: customFace)) + + let block = style("```\nlet x = 1\n```\n", configuration: config) + #expect(font(in: block, at: 6)?.fontName == customFace) + + let text = "some `code` here\n" + let inline = style(text, configuration: config) + let pos = (text as NSString).range(of: "code").location + #expect(font(in: inline, at: pos)?.fontName == customFace) + } + + @Test("inlineCode.fontName overrides inline spans independently") + func inlineCodeFontNameOverridesInline() { + let config = MarkdownEditorConfiguration( + inlineCode: InlineCodeStyle(fontName: customFace) + ) + let serviceFont = MarkdownEditorServices.default.syntaxHighlighter + .codeFont(size: round(base * 0.85)) + + let text = "some `code` here\n" + let inline = style(text, configuration: config) + let pos = (text as NSString).range(of: "code").location + #expect(font(in: inline, at: pos)?.fontName == customFace) + + // Blocks stay on the service font. + let block = style("```\nlet x = 1\n```\n", configuration: config) + #expect(font(in: block, at: 6)?.fontName == serviceFont.fontName) + } + + @Test("an unresolvable name degrades to the service font") + func unresolvableNameFallsBack() { + let config = MarkdownEditorConfiguration( + codeBlock: CodeBlockStyle(fontName: "NoSuchFace-Regular") + ) + let expected = MarkdownEditorServices.default.syntaxHighlighter + .codeFont(size: round(base * 0.85)) + let block = style("```\nlet x = 1\n```\n", configuration: config) + #expect(font(in: block, at: 6)?.fontName == expected.fontName) + } + + // MARK: - Background + + @Test("codeBackground slot replaces the service background; nil keeps it") + func codeBackgroundSlot() { + #expect(MarkdownEditorTheme.default.codeBackground == nil) + + let serviceBackground = MarkdownEditorServices.default.syntaxHighlighter.backgroundColor() + let stock = style("```\nlet x = 1\n```\n") + #expect(background(in: stock, at: 6) == serviceBackground) + + let card = NSColor(calibratedWhite: 0.15, alpha: 1) + let themed = style( + "```\nlet x = 1\n```\n", + configuration: MarkdownEditorConfiguration(theme: MarkdownEditorTheme(codeBackground: card)) + ) + #expect(background(in: themed, at: 6) == card) + + let text = "some `code` here\n" + let inline = style( + text, + configuration: MarkdownEditorConfiguration(theme: MarkdownEditorTheme(codeBackground: card)) + ) + let pos = (text as NSString).range(of: "code").location + #expect(background(in: inline, at: pos) == card) + } +} 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)) + } + } +}