diff --git a/CHANGELOG.md b/CHANGELOG.md
index f78d4b6a..58fbc83f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `NativeTextViewWrapper.onTextMutation` reports exact, completed native edits
for embedders that maintain their own source authority or mirror edits into
another presentation.
+- `MarkdownEditorConfiguration.thematicBreak` (`ThematicBreakStyle`) gives each
+ thematic-break marker its own look. CommonMark treats `---`, `***` and `___`
+ as one construct with one rendering, so an embedder who wanted a novel-style
+ star divider on `***` had no way to ask for one without inventing syntax.
+ Setting `asteriskMark` draws that string centred in the text container in
+ place of the full-width rule; `dashMark` and `underscoreMark` do the same for
+ their markers. All three default to nil, so every existing embedder keeps the
+ rule it already has. Presentation only — the source text is untouched, the
+ caret still reveals the raw `***`, the construct still exports as `
`, and
+ a document written this way reads correctly in any other editor. The mark is
+ a literal string rather than a symbol name, so pick one whose glyphs exist in
+ every font you ship: a glyph the body font lacks does not draw as tofu, it
+ silently falls back to another typeface (`⁂` asked for in a serif renders as
+ Helvetica), and the engine cannot tell that from a deliberate choice. A mark carries a `scale` (a multiple of the
+ body font size, 1 by default), and above 1 the break's line grows to fit
+ rather than the mark overlapping its neighbours. Marks are centred on their
+ INK rather than their layout box, because a glyph like `*` is drawn high in
+ its em — its optical centre sits about a quarter of the font size above the
+ lowercase centre, and that gap grows with the size, so box-centring would let
+ a larger mark climb toward the top of its line.
### Changed
- An ordered list's painted number no longer reverts to the source digit under
diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift
index 7e924865..2f90699d 100644
--- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift
+++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift
@@ -38,6 +38,7 @@ public struct MarkdownEditorConfiguration: Sendable {
public var blockLatex: BlockLatexStyle
public var inlineLatex: InlineLatexStyle
public var blockquote: BlockquoteStyle
+ public var thematicBreak: ThematicBreakStyle
public var link: LinkStyle
public var paragraph: ParagraphStyle
public var overscroll: OverscrollPolicy
@@ -104,6 +105,7 @@ public struct MarkdownEditorConfiguration: Sendable {
blockLatex: BlockLatexStyle = .default,
inlineLatex: InlineLatexStyle = .default,
blockquote: BlockquoteStyle = .default,
+ thematicBreak: ThematicBreakStyle = .default,
link: LinkStyle = .default,
paragraph: ParagraphStyle = .default,
overscroll: OverscrollPolicy = .default,
@@ -132,6 +134,7 @@ public struct MarkdownEditorConfiguration: Sendable {
self.blockLatex = blockLatex
self.inlineLatex = inlineLatex
self.blockquote = blockquote
+ self.thematicBreak = thematicBreak
self.link = link
self.paragraph = paragraph
self.overscroll = overscroll
@@ -354,6 +357,98 @@ public struct TaskCheckboxStyle: Sendable {
public static let `default` = TaskCheckboxStyle()
}
+// MARK: - Thematic breaks
+
+/// How each thematic-break marker draws.
+///
+/// CommonMark gives `---`, `***` and `___` one meaning and one rendering — a
+/// horizontal rule. The marker character survives into this struct so an
+/// embedder can give one of the three a different look without inventing
+/// syntax: a novel-style star divider on `***`, say, while `---` stays a rule.
+/// Every marker still parses as a thematic break and still exports as `
`,
+/// so a document written this way reads correctly in any other editor.
+///
+/// A nil mark (the default for all three) draws the full-width rule. A non-nil
+/// mark is drawn CENTERED in the text container instead, in the body font, and
+/// the source characters stay hidden the way they already are — put a literal
+/// string here (`"* * *"`), not a symbol name. Pick one whose glyphs exist in
+/// EVERY font you ship: a glyph the body font lacks does not draw as tofu, it
+/// silently falls back to another typeface — `⁂` asked for in a serif lands as
+/// Helvetica mid-page — and the engine cannot tell that from a deliberate
+/// choice. An ASCII `*` is the safe end of that spectrum.
+///
+/// The mark is presentation only. The source text is untouched, the caret
+/// still reveals the raw `***` when it enters the line, and copy, export and
+/// find all see the original characters.
+public struct ThematicBreakStyle: Sendable {
+
+ /// A centered mark and the size it draws at.
+ ///
+ /// `scale` is a multiple of the body font size, so a mark keeps its
+ /// proportion when the reader changes the editor's font size. Above 1 the
+ /// break's line grows to fit, rather than the mark overlapping the
+ /// paragraphs around it.
+ ///
+ /// The mark is centered on its INK, not on its layout box. An asterisk is
+ /// drawn high in its em — its optical center sits about a quarter of the
+ /// font size above the lowercase center, and that gap grows with the size
+ /// — so box-centering would let the mark drift toward the top of the line
+ /// as it got bigger. Ink-centering holds it at the optical center of the
+ /// break at any scale, in any font, with no per-font tuning.
+ ///
+ /// A bare string literal works wherever a `Mark` is expected:
+ /// `config.thematicBreak.asteriskMark = "* * *"` is scale 1.
+ public struct Mark: Sendable, Equatable, ExpressibleByStringLiteral {
+ /// The literal text drawn, e.g. `"* * *"`.
+ public var text: String
+ /// Multiple of the body font size. 1 draws at body size.
+ public var scale: CGFloat
+
+ public init(_ text: String, scale: CGFloat = 1) {
+ self.text = text
+ self.scale = max(0.01, scale)
+ }
+
+ public init(stringLiteral value: String) {
+ self.init(value)
+ }
+ }
+
+ /// Centered mark drawn for a `---` break; nil draws the full-width rule.
+ public var dashMark: Mark?
+ /// Centered mark drawn for a `***` break; nil draws the full-width rule.
+ public var asteriskMark: Mark?
+ /// Centered mark drawn for a `___` break; nil draws the full-width rule.
+ public var underscoreMark: Mark?
+
+ public init(
+ dashMark: Mark? = nil,
+ asteriskMark: Mark? = nil,
+ underscoreMark: Mark? = nil
+ ) {
+ self.dashMark = dashMark
+ self.asteriskMark = asteriskMark
+ self.underscoreMark = underscoreMark
+ }
+
+ public static let `default` = ThematicBreakStyle()
+}
+
+extension ThematicBreakStyle {
+ /// The mark configured for a marker character, or nil to draw the rule.
+ /// The parser has already proven the character is one of the three
+ /// (`BlockParser.isThematicBreak`), so `default` is unreachable in practice
+ /// and falls back to the rule rather than guessing.
+ func mark(forMarker marker: unichar) -> Mark? {
+ switch marker {
+ case 0x2D: return dashMark // -
+ case 0x2A: return asteriskMark // *
+ case 0x5F: return underscoreMark // _
+ default: return nil
+ }
+ }
+}
+
// MARK: - Headings
/// Per-level heading metrics. Defaults follow the historical Nodes ratios,
diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift
index 9c6b2ee9..8b1c857a 100644
--- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift
+++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift
@@ -9,6 +9,7 @@
// via NSTextLayoutFragment instead of NSLayoutManager glyph overrides.
import AppKit
+import CoreText
// MARK: - Custom attribute keys for rendering overlays
@@ -18,6 +19,13 @@ extension NSAttributedString.Key {
static let latexIsBlock = NSAttributedString.Key("LatexIsBlock")
static let latexBlockOffsetY = NSAttributedString.Key("LatexBlockOffsetY")
static let thematicBreak = NSAttributedString.Key("ThematicBreak")
+ /// String — the mark to draw CENTERED in place of the full-width rule on a
+ /// line that already carries `.thematicBreak`. Absent means the rule. The
+ /// styler resolves it from `configuration.thematicBreak`, so the fragment
+ /// draws what it is told and never re-reads the marker character.
+ static let thematicBreakMark = NSAttributedString.Key("ThematicBreakMark")
+ /// CGFloat — the mark's size as a multiple of the body font. Absent = 1.
+ static let thematicBreakMarkScale = NSAttributedString.Key("ThematicBreakMarkScale")
/// Int nesting level (1-based) of a blockquote line; the fragment
/// paints that many vertical bars in the left gutter.
static let blockquoteLevel = NSAttributedString.Key("BlockquoteLevel")
@@ -484,12 +492,30 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {
// MARK: - Thematic Breaks (---, ***, ___)
- /// Draw a 1pt horizontal rule across the full container width for any
- /// line fragment whose backing text carries the `.thematicBreak`
- /// attribute. This decouples HR rendering from the source-text length,
- /// so a 3-char `---` looks the same as a 80-char auto-expanded line.
- private func drawThematicBreaks(at point: CGPoint, in context: CGContext) {
- guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return }
+ /// One thematic-break line's decoration. `mark == nil` is the default
+ /// rule and `rect` is the band to fill; otherwise `rect` is the box the
+ /// mark string draws into, already centered in the text container.
+ /// `rect` is what the reader sees: the rule's band, or the mark's INK box
+ /// (not its layout box — see `ThematicBreakStyle.Mark`). `drawOrigin` is
+ /// where the string is actually drawn to land that ink there.
+ struct ThematicBreakDecoration {
+ let rect: CGRect
+ let mark: String?
+ let font: NSFont
+ let drawOrigin: CGPoint
+ }
+
+ /// Geometry for every thematic break in this fragment, in the same
+ /// fragment-local space `draw(at:)` works in. Split out from the drawing so
+ /// centering is assertable headlessly, the way `blockBackgroundFills(at:)`
+ /// is — nothing here touches a graphics context.
+ ///
+ /// The rule spans the full container width regardless of how many source
+ /// characters produced it, so a 3-char `---` matches an 80-char one. A mark
+ /// is measured in the view's BASE font, not the run's: the run carries the
+ /// hidden-marker styling that makes the source characters invisible.
+ func thematicBreakDecorations(at point: CGPoint) -> [ThematicBreakDecoration] {
+ guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return [] }
var hasThematic = false
ts.enumerateAttribute(.thematicBreak, in: range, options: []) { value, _, stop in
if value as? Bool == true {
@@ -497,25 +523,21 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {
stop.pointee = true
}
}
- guard hasThematic else { return }
+ guard hasThematic else { return [] }
let containerWidth = textLayoutManager?.textContainer?.size.width ?? layoutFragmentFrame.width
- let theme = (textLayoutManager?.textContainer?.textView as? NativeTextView)?
- .configuration.theme ?? .default
-
- NSGraphicsContext.saveGraphicsState()
- defer { NSGraphicsContext.restoreGraphicsState() }
- let nsContext = NSGraphicsContext(cgContext: context, flipped: true)
- NSGraphicsContext.current = nsContext
-
- let strokeColor = theme.strikethroughColor.withAlphaComponent(0.4)
- strokeColor.setFill()
-
- // Walk each line fragment in this layout fragment and paint a
- // band on those whose first character carries the marker. (HR
- // tokens are always single-line, but the loop is robust if a
- // future caller ever stacks several rules in one paragraph.)
- let fragLocation = fragmentNSRange?.location ?? 0
+ let textView = textLayoutManager?.textContainer?.textView
+ let font = (textView as? NativeTextView)?.baseFont
+ ?? textView?.font
+ ?? NSFont.systemFont(ofSize: NSFont.systemFontSize)
+ let containerX = point.x - layoutFragmentFrame.origin.x
+
+ // Walk each line fragment in this layout fragment and decorate those
+ // whose first character carries the marker. (HR tokens are always
+ // single-line, but the loop is robust if a future caller ever stacks
+ // several rules in one paragraph.)
+ var out: [ThematicBreakDecoration] = []
+ let fragLocation = range.location
for lineFragment in textLineFragments {
let lr = lineFragment.characterRange
let docStart = fragLocation + lr.location
@@ -523,19 +545,93 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {
// characterRange lands at exactly `tsLen` — `attribute(at:)` needs
// a strictly in-bounds index, so skip the sentinel.
guard docStart < ts.length else { continue }
- let isHR = ts.attribute(.thematicBreak, at: docStart, effectiveRange: nil) as? Bool == true
+ guard ts.attribute(.thematicBreak, at: docStart, effectiveRange: nil) as? Bool == true else { continue }
let tb = lineFragment.typographicBounds
- if isHR {
+
+ if let mark = ts.attribute(.thematicBreakMark, at: docStart, effectiveRange: nil) as? String,
+ !mark.isEmpty {
+ let scale = (ts.attribute(.thematicBreakMarkScale, at: docStart, effectiveRange: nil) as? CGFloat) ?? 1
+ let markFont = scale == 1
+ ? font
+ : NSFont(descriptor: font.fontDescriptor, size: font.pointSize * scale) ?? font
+ let attrs: [NSAttributedString.Key: Any] = [.font: markFont]
+ let layoutSize = (mark as NSString).size(withAttributes: attrs)
+ let lineCenterY = point.y + tb.origin.y + tb.height / 2
+
+ // Ink bounds relative to the baseline, y up. Centering on this
+ // rather than on the layout box is what keeps a high-drawn
+ // glyph like `*` optically centered as the scale grows.
+ let line = CTLineCreateWithAttributedString(
+ NSAttributedString(string: mark, attributes: attrs)
+ )
+ let ink = CTLineGetImageBounds(line, nil)
+ let inkIsUsable = !ink.isNull && ink.height > 0
+
+ // Flipped context: y grows downward, so ink that sits ABOVE the
+ // baseline lands at `baseline - ink.maxY`.
+ let baselineY = inkIsUsable
+ ? lineCenterY + ink.midY
+ : lineCenterY - layoutSize.height / 2 + markFont.ascender
+ // Centered on ink horizontally too: a mark's advance width
+ // includes side bearings that need not be symmetric, so
+ // centering the layout box can leave the ink visibly off-axis.
+ let layoutX = inkIsUsable
+ ? containerX + containerWidth / 2 - ink.midX
+ : containerX + (containerWidth - layoutSize.width) / 2
+ let inkRect = CGRect(
+ x: inkIsUsable ? layoutX + ink.minX : layoutX,
+ y: inkIsUsable ? baselineY - ink.maxY : lineCenterY - layoutSize.height / 2,
+ width: inkIsUsable ? ink.width : layoutSize.width,
+ height: inkIsUsable ? ink.height : layoutSize.height
+ )
+ out.append(ThematicBreakDecoration(
+ rect: inkRect,
+ mark: mark,
+ font: markFont,
+ drawOrigin: CGPoint(x: layoutX, y: baselineY - markFont.ascender)
+ ))
+ } else {
// tb.origin.y is already relative to this layout fragment.
let centerY = point.y + tb.origin.y + tb.height / 2
- let bandRect = CGRect(
- x: point.x - layoutFragmentFrame.origin.x,
- y: centerY - 0.5,
- width: containerWidth,
- height: 1
- )
- NSBezierPath(rect: bandRect).fill()
+ out.append(ThematicBreakDecoration(
+ rect: CGRect(x: containerX, y: centerY - 0.5, width: containerWidth, height: 1),
+ mark: nil,
+ font: font,
+ drawOrigin: CGPoint(x: containerX, y: centerY - 0.5)
+ ))
+ }
+ }
+ return out
+ }
+
+ /// Paint what `thematicBreakDecorations(at:)` worked out: a full-width rule,
+ /// or the configured mark centered in the container.
+ private func drawThematicBreaks(at point: CGPoint, in context: CGContext) {
+ let decorations = thematicBreakDecorations(at: point)
+ guard !decorations.isEmpty else { return }
+
+ let theme = (textLayoutManager?.textContainer?.textView as? NativeTextView)?
+ .configuration.theme ?? .default
+
+ NSGraphicsContext.saveGraphicsState()
+ defer { NSGraphicsContext.restoreGraphicsState() }
+ let nsContext = NSGraphicsContext(cgContext: context, flipped: true)
+ NSGraphicsContext.current = nsContext
+
+ let ruleColor = theme.strikethroughColor.withAlphaComponent(0.4)
+ for decoration in decorations {
+ guard let mark = decoration.mark else {
+ ruleColor.setFill()
+ NSBezierPath(rect: decoration.rect).fill()
+ continue
}
+ // Ink, not a hairline: the rule colour is deliberately faint and
+ // reads as a smudge on glyphs, so a mark takes the muted text
+ // colour the blockquote bars and hidden markers already use.
+ (mark as NSString).draw(
+ at: decoration.drawOrigin,
+ withAttributes: [.font: decoration.font, .foregroundColor: theme.mutedText]
+ )
}
}
diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift
index 71c04f97..a4f7a92e 100644
--- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift
+++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift
@@ -181,6 +181,12 @@ enum MarkdownASTStyler {
#"\[[^\]\r\n]+\]\([^)\r\n]*$"#, #"\[[^\]\r\n]+\]\(\)"#].compactMap { regex($0, false) }
/// Tag a thematic-break line for a full-width rule (AST-driven); suppressed while the caret edits it.
+ ///
+ /// When `configuration.thematicBreak` maps this line's marker to a mark,
+ /// `.thematicBreakMark` rides along and the fragment draws that string
+ /// centered instead of the rule. Resolving here rather than at draw time
+ /// keeps the presentation decision next to the configuration (`Ctx` already
+ /// carries it) and leaves the fragment with nothing to look up.
private static func styleThematicBreak(range: NSRange, ctx: Ctx, into attrs: inout [StyledRange]) {
var hr = range
while hr.length > 0 {
@@ -190,8 +196,40 @@ enum MarkdownASTStyler {
}
guard hr.length > 0,
!(NSLocationInRange(ctx.caret, hr) || ctx.caret == NSMaxRange(hr)) else { return }
- attrs.append((hr, [.foregroundColor: NSColor.clear, .thematicBreak: true]))
- attrs.append((hr, [.paragraphStyle: NSMutableParagraphStyle()]))
+ var tags: [NSAttributedString.Key: Any] = [
+ .foregroundColor: NSColor.clear,
+ .thematicBreak: true,
+ ]
+ let mark = ctx.config.thematicBreak.mark(forMarker: thematicBreakMarker(in: hr, ctx: ctx))
+ if let mark {
+ tags[.thematicBreakMark] = mark.text
+ tags[.thematicBreakMarkScale] = mark.scale
+ }
+ attrs.append((hr, tags))
+
+ // A mark bigger than body size needs the line to grow with it, or it
+ // would be drawn over the paragraphs above and below (the fragment
+ // paints outside the line box; it does not reserve space).
+ let para = NSMutableParagraphStyle()
+ if let mark, mark.scale > 1 {
+ let height = ceil(ctx.baseLineHeight * mark.scale)
+ para.minimumLineHeight = height
+ para.maximumLineHeight = height
+ }
+ attrs.append((hr, [.paragraphStyle: para]))
+ }
+
+ /// The marker character of a thematic-break line: its first non-whitespace
+ /// character. Sound by construction — `BlockParser.isThematicBreak` accepts
+ /// the line only when every non-whitespace character is the same one of
+ /// `-`/`*`/`_`. Only trailing newlines are trimmed from the block range, so
+ /// leading indent has to be skipped here.
+ private static func thematicBreakMarker(in hr: NSRange, ctx: Ctx) -> unichar {
+ for offset in 0.. configured mark -> attribute) and the fragment
+// positions (rule spans the container, mark centres in it). The attribute
+// layer especially — every reader of `.thematicBreak` type-tests it as Bool
+// off `Any`, so a wrong value type there compiles clean and silently draws
+// nothing at all.
+//
+
+import AppKit
+import Foundation
+import Testing
+@testable import MarkdownEngine
+
+@MainActor
+@Suite("Thematic break marks")
+struct ThematicBreakMarkTests {
+
+ private let fontSize: CGFloat = 14
+ private var fontName: String { NSFont.systemFont(ofSize: 14).fontName }
+
+ /// `(location, mark)` for every styled thematic break. `mark == nil` is the
+ /// default full-width rule; a non-nil mark is drawn centred instead.
+ private func breaks(_ attrs: [StyledRange]) -> [(loc: Int, mark: String?)] {
+ attrs.compactMap { entry -> (Int, String?)? in
+ guard entry.1[.thematicBreak] as? Bool == true else { return nil }
+ return (entry.0.location, entry.1[.thematicBreakMark] as? String)
+ }
+ .sorted { $0.0 < $1.0 }
+ }
+
+ private func style(
+ _ text: String,
+ caret: Int = -1,
+ configuration: MarkdownEditorConfiguration = .default
+ ) -> [StyledRange] {
+ MarkdownASTStyler.styleAttributes(
+ text: text, fontName: fontName, fontSize: fontSize,
+ caretLocation: caret, configuration: configuration
+ )
+ }
+
+ private func starConfiguration(
+ _ mark: String = "* * *",
+ scale: CGFloat = 1
+ ) -> MarkdownEditorConfiguration {
+ var config = MarkdownEditorConfiguration.default
+ config.thematicBreak.asteriskMark = ThematicBreakStyle.Mark(mark, scale: scale)
+ return config
+ }
+
+ // MARK: - Styling
+
+ @Test("by default every marker is a rule and carries no mark")
+ func defaultsAreRules() {
+ for source in ["---", "***", "___"] {
+ let found = breaks(style("a\n\n\(source)\n\nb"))
+ #expect(found.count == 1, "\(source) should style one thematic break")
+ #expect(found.first?.mark == nil, "\(source) should carry no mark by default")
+ }
+ }
+
+ @Test("a configured mark applies to its own marker only")
+ func markAppliesToConfiguredMarkerOnly() {
+ let config = starConfiguration()
+ #expect(breaks(style("a\n\n***\n\nb", configuration: config)).first?.mark == "* * *")
+ #expect(breaks(style("a\n\n---\n\nb", configuration: config)).first?.mark == nil)
+ #expect(breaks(style("a\n\n___\n\nb", configuration: config)).first?.mark == nil)
+ }
+
+ @Test("each marker maps to its own slot")
+ func everyMarkerHasItsOwnSlot() {
+ var config = MarkdownEditorConfiguration.default
+ config.thematicBreak.dashMark = "dash"
+ config.thematicBreak.asteriskMark = "star"
+ config.thematicBreak.underscoreMark = "under"
+ #expect(breaks(style("---", configuration: config)).first?.mark == "dash")
+ #expect(breaks(style("***", configuration: config)).first?.mark == "star")
+ #expect(breaks(style("___", configuration: config)).first?.mark == "under")
+ }
+
+ @Test("a longer run is the same break — the mark does not repeat")
+ func longerRunsResolveIdentically() {
+ let config = starConfiguration()
+ #expect(breaks(style("*****", configuration: config)).first?.mark == "* * *")
+ #expect(breaks(style("**********", configuration: config)).first?.mark == "* * *")
+ }
+
+ /// The block range keeps its leading indent — only trailing newlines are
+ /// trimmed — so the marker scan has to skip whitespace to find it.
+ @Test("an indented break still resolves its marker")
+ func indentedBreakResolvesMarker() {
+ let config = starConfiguration()
+ #expect(breaks(style(" ***", configuration: config)).first?.mark == "* * *")
+ #expect(breaks(style("\t***", configuration: config)).first?.mark == "* * *")
+ #expect(breaks(style(" \t ***", configuration: config)).first?.mark == "* * *")
+ }
+
+ @Test("the caret still reveals the raw source, mark or no mark")
+ func caretSuppressionSurvives() {
+ let text = "a\n\n***\n\nb"
+ // The break occupies locations 3...5.
+ #expect(breaks(style(text, caret: 4, configuration: starConfiguration())).isEmpty)
+ #expect(breaks(style(text, caret: 4)).isEmpty)
+ }
+
+ @Test("the trailing newline stays outside both attributes")
+ func trailingNewlineIsNotDecorated() throws {
+ let attrs = style("***\n\nb", configuration: starConfiguration())
+ let entry = try #require(attrs.first { $0.1[.thematicBreak] as? Bool == true })
+ #expect(entry.0 == NSRange(location: 0, length: 3),
+ "decoration should stop before the newline, got \(entry.0)")
+ }
+
+ /// Every other custom attribute value in the engine is an ObjC-bridgeable
+ /// primitive. A boxed Swift enum here would break `as? Bool` at three call
+ /// sites with no compiler diagnostic, so the types are pinned deliberately.
+ @Test("attribute values stay ObjC-bridgeable")
+ func attributeValuesAreBridgeable() throws {
+ let attrs = style("***", configuration: starConfiguration())
+ let entry = try #require(attrs.first { $0.1[.thematicBreak] != nil })
+ #expect(entry.1[.thematicBreak] is Bool)
+ #expect(entry.1[.thematicBreakMark] is String)
+ }
+
+ // MARK: - Geometry
+
+ /// A laid-out editor whose storage carries the styler's own attributes.
+ private func makeTextView(
+ _ text: String,
+ configuration: MarkdownEditorConfiguration,
+ width: CGFloat = 320
+ ) -> NativeTextView {
+ _ = NSApplication.shared
+ let tv = NativeTextView(frame: NSRect(x: 0, y: 0, width: width, height: 400))
+ tv.configuration = configuration
+ let (font, style) = TextStylingService.makeBaseFontAndStyle(
+ fontName: fontName,
+ fontSize: fontSize,
+ layoutBridge: tv.layoutBridge,
+ configuration: configuration
+ )
+ tv.baseFont = font
+ tv.textContainer?.size = NSSize(width: width, height: .greatestFiniteMagnitude)
+ let storage = NSMutableAttributedString(
+ string: text,
+ attributes: [.font: font, .paragraphStyle: style]
+ )
+ for (range, attributes) in MarkdownASTStyler.styleAttributes(
+ text: text, fontName: fontName, fontSize: fontSize,
+ caretLocation: -1, configuration: configuration
+ ) {
+ storage.addAttributes(attributes, range: range)
+ }
+ tv.textStorage?.setAttributedString(storage)
+ return tv
+ }
+
+ private func decorations(in tv: NativeTextView) -> [MarkdownTextLayoutFragment.ThematicBreakDecoration] {
+ guard let tlm = tv.textLayoutManager, let tcm = tlm.textContentManager else { return [] }
+ let delegate = MarkdownLayoutManagerDelegate()
+ tlm.delegate = delegate
+ tlm.invalidateLayout(for: tlm.documentRange)
+ tlm.ensureLayout(for: tlm.documentRange)
+
+ var out: [MarkdownTextLayoutFragment.ThematicBreakDecoration] = []
+ tlm.enumerateTextLayoutFragments(from: tcm.documentRange.location, options: [.ensuresLayout]) { fragment in
+ guard let fragment = fragment as? MarkdownTextLayoutFragment else { return true }
+ out += fragment.thematicBreakDecorations(at: fragment.layoutFragmentFrame.origin)
+ return true
+ }
+ return out
+ }
+
+ @Test("the default rule spans the whole container, 1pt tall")
+ func ruleSpansContainer() throws {
+ let width: CGFloat = 320
+ let tv = makeTextView("a\n\n---\n\nb", configuration: .default, width: width)
+ let found = decorations(in: tv)
+ #expect(found.count == 1)
+ let rule = try #require(found.first)
+ #expect(rule.mark == nil)
+ #expect(abs(rule.rect.width - width) < 0.01, "rule should span \(width), got \(rule.rect.width)")
+ #expect(abs(rule.rect.height - 1) < 0.01)
+ #expect(abs(rule.rect.minX) < 0.01, "rule should start at the container edge")
+ }
+
+ @Test("a mark is narrower than the container and centred in it")
+ func markIsCentredInContainer() throws {
+ let width: CGFloat = 320
+ let tv = makeTextView("a\n\n***\n\nb", configuration: starConfiguration(), width: width)
+ let found = decorations(in: tv)
+ #expect(found.count == 1)
+ let decoration = try #require(found.first)
+ #expect(decoration.mark == "* * *")
+ #expect(decoration.rect.width > 0)
+ #expect(decoration.rect.width < width, "mark should not span the container")
+ let leading = decoration.rect.minX
+ let trailing = width - decoration.rect.maxX
+ #expect(abs(leading - trailing) < 0.01,
+ "mark should be centred: \(leading)pt leading vs \(trailing)pt trailing")
+ }
+
+ @Test("the mark sits inside its line box, so it cannot be clipped")
+ func markFitsTheLineBox() throws {
+ let tv = makeTextView("a\n\n***\n\nb", configuration: starConfiguration())
+ let decoration = try #require(decorations(in: tv).first)
+ let rule = try #require(decorations(in: makeTextView("a\n\n---\n\nb", configuration: .default)).first)
+ // Both breaks occupy the same line box; the rule's band is centred in
+ // it, so the mark's box must straddle that same centre line.
+ let ruleCentre = rule.rect.midY
+ #expect(abs(decoration.rect.midY - ruleCentre) < 0.51,
+ "mark centre \(decoration.rect.midY) should match the rule's \(ruleCentre)")
+ }
+
+ @Test("no thematic break, no decoration")
+ func plainTextIsNotDecorated() {
+ let tv = makeTextView("just a paragraph", configuration: starConfiguration())
+ #expect(decorations(in: tv).isEmpty)
+ }
+
+ // MARK: - Size
+
+ @Test("a string literal is still a mark, at body size")
+ func stringLiteralIsScaleOne() {
+ var config = MarkdownEditorConfiguration.default
+ config.thematicBreak.asteriskMark = "* * *"
+ #expect(config.thematicBreak.asteriskMark?.text == "* * *")
+ #expect(config.thematicBreak.asteriskMark?.scale == 1)
+ }
+
+ @Test("a scaled mark draws bigger")
+ func scaledMarkIsBigger() throws {
+ let small = try #require(decorations(in: makeTextView(
+ "***", configuration: starConfiguration(scale: 1))).first)
+ let large = try #require(decorations(in: makeTextView(
+ "***", configuration: starConfiguration(scale: 2))).first)
+ #expect(large.font.pointSize > small.font.pointSize)
+ #expect(large.rect.height > small.rect.height,
+ "scale 2 ink \(large.rect.height) should exceed scale 1 ink \(small.rect.height)")
+ }
+
+ /// The whole reason marks are ink-centred: `*` is drawn high in its em, so
+ /// box-centring would let the mark climb toward the top of the line as it
+ /// grew. The drawn ink must stay centred on the line at every scale.
+ @Test("a mark stays optically centred as it grows")
+ func markStaysCentredAtEveryScale() throws {
+ for scale in [1.0, 1.5, 2.0, 3.0] as [CGFloat] {
+ let tv = makeTextView("a\n\n***\n\nb", configuration: starConfiguration(scale: scale))
+ let decoration = try #require(decorations(in: tv).first)
+ let rule = try #require(decorations(in: makeTextView(
+ "a\n\n---\n\nb", configuration: .default)).first)
+ _ = rule
+ // Compare against the line box the mark actually occupies.
+ let lineCentre = lineCentreOfBreak(in: tv)
+ #expect(abs(decoration.rect.midY - lineCentre) < 0.51,
+ "scale \(scale): ink centre \(decoration.rect.midY) vs line centre \(lineCentre)")
+ }
+ }
+
+ @Test("a scaled mark grows its line instead of overlapping neighbours")
+ func scaledMarkGrowsTheLine() throws {
+ let plain = lineHeightOfBreak(in: makeTextView("a\n\n***\n\nb", configuration: starConfiguration(scale: 1)))
+ let big = lineHeightOfBreak(in: makeTextView("a\n\n***\n\nb", configuration: starConfiguration(scale: 3)))
+ #expect(big > plain * 2, "line should grow with the mark (\(plain) -> \(big))")
+ }
+
+ /// Line box of the thematic-break line, in the same space the decorations use.
+ private func breakLineBounds(in tv: NativeTextView) -> CGRect {
+ guard let tlm = tv.textLayoutManager, let tcm = tlm.textContentManager else { return .zero }
+ let delegate = MarkdownLayoutManagerDelegate()
+ tlm.delegate = delegate
+ tlm.invalidateLayout(for: tlm.documentRange)
+ tlm.ensureLayout(for: tlm.documentRange)
+
+ var out = CGRect.zero
+ tlm.enumerateTextLayoutFragments(from: tcm.documentRange.location, options: [.ensuresLayout]) { fragment in
+ guard let md = fragment as? MarkdownTextLayoutFragment,
+ !md.thematicBreakDecorations(at: md.layoutFragmentFrame.origin).isEmpty,
+ let lineFragment = md.textLineFragments.first else { return true }
+ let origin = md.layoutFragmentFrame.origin
+ let tb = lineFragment.typographicBounds
+ out = CGRect(x: origin.x, y: origin.y + tb.origin.y, width: tb.width, height: tb.height)
+ return false
+ }
+ return out
+ }
+
+ private func lineCentreOfBreak(in tv: NativeTextView) -> CGFloat {
+ breakLineBounds(in: tv).midY
+ }
+
+ private func lineHeightOfBreak(in tv: NativeTextView) -> CGFloat {
+ breakLineBounds(in: tv).height
+ }
+}