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 + } +}