Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<hr>`, 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `<hr>`,
/// 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,
Expand Down
160 changes: 128 additions & 32 deletions Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// via NSTextLayoutFragment instead of NSLayoutManager glyph overrides.

import AppKit
import CoreText

// MARK: - Custom attribute keys for rendering overlays

Expand All @@ -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")
Expand Down Expand Up @@ -484,58 +492,146 @@ 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 {
hasThematic = true
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
// TextKit 2 appends a synthetic trailing empty line fragment whose
// 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]
)
}
}

Expand Down
Loading
Loading