From 6f9b98fa5b6d48888426e6ce14842c7178f519c6 Mon Sep 17 00:00:00 2001 From: Nico <80885367+Nicolas-Py@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:31:56 +0200 Subject: [PATCH] Let each thematic-break marker carry its own look CommonMark gives `---`, `***` and `___` one meaning and one rendering, so an embedder who wants a novel-style star divider on `***` has had to invent syntax for it -- and any syntax they invent stops being a thematic break everywhere else. The marker character was already parsed and then discarded; keeping it costs nothing and buys the distinction for free. `MarkdownEditorConfiguration.thematicBreak` maps each of the three markers to an optional mark string. Nil, the default for all three, draws today's full-width rule, so every existing embedder is unaffected. A non-nil mark is drawn centred in the text container in the body font. Presentation only: the source text is untouched, the caret still reveals the raw `***`, and the construct still exports as `
`. Three things this deliberately does NOT do. The marker is recovered in the styler from the line's first non-whitespace character rather than carried as an associated value on `BlockKind`/`BlockNode` -- `Ctx` already holds both the string and the configuration, and the payload would have forced edits through the parser, the AST, the tokenizer and two test files to move information its only consumer can already see. The `.thematicBreak` attribute keeps its `Bool` value and the mark rides on a second key, because all three readers type-test it as `as? Bool` off `Any`: a changed value type compiles clean, returns nil everywhere, and silently blanks every rule in the document with no test to catch it. And the grammar is untouched, so a spaced `* * *` remains a list item as before -- correcting that is a separate change with its own blast radius. `drawThematicBreaks` splits into `thematicBreakDecorations(at:)` plus a thin draw loop, mirroring `blockBackgroundFills(at:)`, so the geometry is assertable headlessly. That is new coverage: nothing previously read what the styler emits for a thematic break. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 20 ++ .../MarkdownEditorConfiguration.swift | 95 ++++++ .../Renderer/MarkdownTextLayoutFragment.swift | 160 +++++++-- .../Styling/MarkdownASTStyler.swift | 42 ++- .../ThematicBreakMarkTests.swift | 304 ++++++++++++++++++ 5 files changed, 587 insertions(+), 34 deletions(-) create mode 100644 Tests/MarkdownEngineTests/ThematicBreakMarkTests.swift 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 + } +}