Skip to content
Draft
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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Ordered-marker lettering per depth: `ListStyle.orderedMarkerStyles` renders
nested ordered markers as letters or roman numerals (`[.numeric,
.lowerAlpha, .lowerRoman]` → `1.` / `a.` / `i.`, cycling below that). Only
the painted overlay changes — the source digits stay valid CommonMark. The
default (a single `.numeric`) keeps every level numeric as before.
- `MarkdownEditorTheme.listMarker` colors the painted bullet `•` and ordered
markers independently of `bodyText` (`nil`, the default, keeps body ink).
- Opt-in list indent grid: `ListStyle.markerTextGap` puts list markers on a
deterministic `depth × indentPerLevel` grid (level 1 aligned with the body
origin, structural nesting depth so ordered lists step one level per
parent), neutralizes the raw source whitespace as the visual indent, and
hangs content a fixed slot after the marker for every marker kind (bullet,
any digit count, task box — which left-aligns to the slot origin). Wrapped
lines hang at the content edge. `nil` (the default) keeps the historical
geometry exactly.
- Custom heading typeface and color: `HeadingStyle.fontName` renders headings
in a specific PostScript face (honored exactly, so the chosen weight is
respected; an unresolvable name falls back to the stock bold base font),
and `MarkdownEditorTheme.headingText` colors heading text independently of
`bodyText` — the `#` glyphs stay on `headingMarker`, and inline constructs
inside a heading keep their own ink (both opt-in; the defaults are
unchanged).

## [0.11.0] - 2026-07-31

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,64 @@ public struct InlineCodeStyle: Sendable {

// MARK: - Lists

/// How an ordered item's computed position is rendered in its painted marker.
///
/// Only the painted overlay changes; the source digits are untouched, so the
/// file stays valid CommonMark whatever it says (mirroring how display
/// numbering already works).
public enum OrderedMarkerStyle: Sendable, Equatable {
/// `1.` `2.` `3.` — the historical rendering.
case numeric
/// `a.` `b.` … `z.` `aa.` (spreadsheet-style bijective base-26).
case lowerAlpha
/// `A.` `B.` … `Z.` `AA.`
case upperAlpha
/// `i.` `ii.` `iii.` `iv.` …
case lowerRoman
/// `I.` `II.` `III.` `IV.` …
case upperRoman

/// The marker label (without punctuation) for the 1-based `number`.
/// Zero/negative positions cannot come out of display numbering, but a
/// literal `0.` in the source falls back to the digits themselves.
public func label(for number: Int) -> String {
guard number > 0 else { return "\(number)" }
switch self {
case .numeric: return "\(number)"
case .lowerAlpha: return Self.alphaLabel(number)
case .upperAlpha: return Self.alphaLabel(number).uppercased()
case .lowerRoman: return Self.romanLabel(number)
case .upperRoman: return Self.romanLabel(number).uppercased()
}
}

/// Bijective base-26: 1 → a … 26 → z, 27 → aa, 28 → ab.
private static func alphaLabel(_ number: Int) -> String {
var n = number
var label = ""
while n > 0 {
n -= 1
label = String(UnicodeScalar(UInt8(97 + n % 26))) + label
n /= 26
}
return label
}

private static func romanLabel(_ number: Int) -> String {
let values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]
let numerals = ["m", "cm", "d", "cd", "c", "xc", "l", "xl", "x", "ix", "v", "iv", "i"]
var n = number
var label = ""
for (value, numeral) in zip(values, numerals) {
while n >= value {
label += numeral
n -= value
}
}
return label
}
}

/// Behavior toggles and metrics for ordered / unordered list editing.
public struct ListStyle: Sendable {
/// Master switch for list-related editing helpers (auto-continue,
Expand All @@ -299,19 +357,63 @@ public struct ListStyle: Sendable {
public var maximumNestingLevel: Int
/// Extra line height added on top of the default to give list items room.
public var extraLineHeight: CGFloat
/// Marker rendering style per nesting depth for ordered items. Depth 0
/// (top level) uses the first entry and deeper levels CYCLE through the
/// array, so `[.numeric, .lowerAlpha, .lowerRoman]` renders `1.` / `a.` /
/// `i.` and starts over one level further down. The default — a single
/// `.numeric` — keeps every level numeric, the historical rendering.
public var orderedMarkerStyles: [OrderedMarkerStyle]
/// Width (in points) of the marker slot — from the marker glyph's left
/// edge to the item content's left edge. Setting it opts the list layout
/// into a fixed indent GRID; `nil` (the default) keeps the historical
/// geometry exactly.
///
/// Historically the visual indent is the raw source whitespace: every
/// first line starts at a flat `indentPerLevel`, nesting shows only as
/// the advance of the leading spaces/tabs (≈8pt for two spaces — far off
/// any design grid), and the marker→content gap is whatever `- ` happens
/// to measure.
///
/// With a gap set, geometry becomes deterministic:
/// - a level-`n` item's marker starts at `n × indentPerLevel` from the
/// text origin (level 1 aligns with body text),
/// - the leading source whitespace collapses (hidden-marker font; tabs
/// advance by a sub-point interval) so it no longer shifts the line,
/// - content starts `markerTextGap` after the marker for every marker
/// kind (bullet, any digit count, task box), via a kern on the final
/// spacer character — so wrapped lines and the caret keep working on
/// real text advances,
/// - wrapped lines hang at the content edge (`depth × indentPerLevel +
/// markerTextGap`).
///
/// A slot narrower than the marker itself widens just enough to keep the
/// spacer's advance positive. Note that in grid mode literal tabs inside
/// item CONTENT also advance by the sub-point interval.
public var markerTextGap: CGFloat?

public init(
helpersEnabled: Bool = true,
autoClosePairsEnabled: Bool = true,
indentPerLevel: CGFloat = 27.5,
maximumNestingLevel: Int = 3,
extraLineHeight: CGFloat = 2
extraLineHeight: CGFloat = 2,
orderedMarkerStyles: [OrderedMarkerStyle] = [.numeric],
markerTextGap: CGFloat? = nil
) {
self.helpersEnabled = helpersEnabled
self.autoClosePairsEnabled = autoClosePairsEnabled
self.indentPerLevel = indentPerLevel
self.maximumNestingLevel = maximumNestingLevel
self.extraLineHeight = extraLineHeight
self.orderedMarkerStyles = orderedMarkerStyles
self.markerTextGap = markerTextGap
}

/// The ordered-marker style for a 0-based nesting `depth`, cycling
/// through ``orderedMarkerStyles`` (an empty array reads as `.numeric`).
public func orderedMarkerStyle(forDepth depth: Int) -> OrderedMarkerStyle {
guard !orderedMarkerStyles.isEmpty else { return .numeric }
return orderedMarkerStyles[max(0, depth) % orderedMarkerStyles.count]
}

public static let `default` = ListStyle()
Expand Down Expand Up @@ -349,15 +451,30 @@ public struct TaskCheckboxStyle: Sendable {
/// Per-level heading metrics. Defaults follow the historical Nodes ratios,
/// which are loosely based on browser default heading sizes.
public struct HeadingStyle: Sendable {
/// PostScript name of the typeface used for heading text, for example
/// `"AvenirNext-DemiBold"`. `nil` (the default) keeps the historical
/// behavior: headings render in the editor's base font with the bold
/// trait added.
///
/// The name is honored exactly, so the chosen face's weight and style
/// are respected — pick a `-Bold` / `-Semibold` face for heavier
/// headings. Emphasis inside a heading still composes on top of it:
/// bold / italic add their traits while the family and the per-level
/// size are kept. A name that doesn't resolve falls back to the default
/// heading font at draw time, so a typo degrades to the stock look
/// instead of changing metrics.
public var fontName: String?
/// Font-size multiplier per heading level (1...6).
public var fontMultipliers: [CGFloat]
/// Top spacing in `em` units per heading level (1...6).
public var topSpacingEm: [CGFloat]

public init(
fontName: String? = nil,
fontMultipliers: [CGFloat] = [2.0, 1.5, 1.17, 1.0, 0.83, 0.67],
topSpacingEm: [CGFloat] = [0.35, 0.30, 0.25, 0.20, 0.15, 0.10]
) {
self.fontName = fontName
self.fontMultipliers = fontMultipliers
self.topSpacingEm = topSpacingEm
}
Expand Down
19 changes: 19 additions & 0 deletions Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,23 @@ public struct MarkdownEditorTheme: Sendable {
/// Foreground color for content the engine wants to deemphasize further
/// than `mutedText` — for example, broken wiki-links.
public var disabledText: NSColor
/// Foreground color for heading text. `nil` (the default) keeps the
/// historical behavior: headings render in ``bodyText`` like the rest
/// of the document.
///
/// Only the heading's own text takes this color. The `#` marker glyphs
/// stay on ``headingMarker``, and inline constructs inside a heading
/// (links, inline code, extension spans) keep their own colors, exactly
/// as they do over ``bodyText``.
public var headingText: NSColor?
/// Foreground color for heading marker glyphs (`#`, `##`, …).
public var headingMarker: NSColor
/// Foreground color for painted list marker glyphs — the bullet `•` and
/// the ordered `1.` overlays, including the raw source characters those
/// painters reveal while the marker sits inside a selection. `nil` (the
/// default) keeps the historical behavior: markers render in
/// ``bodyText`` like the item content.
public var listMarker: NSColor?

// MARK: Links

Expand Down Expand Up @@ -85,7 +100,9 @@ public struct MarkdownEditorTheme: Sendable {
bodyText: NSColor = .labelColor,
mutedText: NSColor = .secondaryLabelColor,
disabledText: NSColor = .tertiaryLabelColor,
headingText: NSColor? = nil,
headingMarker: NSColor = .gray,
listMarker: NSColor? = nil,
link: NSColor = .linkColor,
incompleteLink: NSColor = .systemBlue,
findMatchHighlight: NSColor = .systemYellow,
Expand All @@ -98,7 +115,9 @@ public struct MarkdownEditorTheme: Sendable {
self.bodyText = bodyText
self.mutedText = mutedText
self.disabledText = disabledText
self.headingText = headingText
self.headingMarker = headingMarker
self.listMarker = listMarker
self.link = link
self.incompleteLink = incompleteLink
self.findMatchHighlight = findMatchHighlight
Expand Down
16 changes: 11 additions & 5 deletions Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,9 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {
let isSelected = selectionRanges.contains(where: { NSIntersectionRange($0, attrRange).length > 0 })
let raw = storageString.substring(with: attrRange)
let glyph = (isSelected ? raw : "•") as NSString
let glyphAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: theme.bodyText]
let glyphAttrs: [NSAttributedString.Key: Any] = [
.font: font, .foregroundColor: theme.listMarker ?? theme.bodyText,
]

let markerWidth = (raw as NSString).size(withAttributes: [.font: font]).width
let glyphWidth = glyph.size(withAttributes: glyphAttrs).width
Expand Down Expand Up @@ -589,7 +591,9 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {
let isSelected = selectionRanges.contains(where: { NSIntersectionRange($0, attrRange).length > 0 })
let raw = storageString.substring(with: attrRange)
let glyph = (isSelected ? raw : number) as NSString
let glyphAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: theme.bodyText]
let glyphAttrs: [NSAttributedString.Key: Any] = [
.font: font, .foregroundColor: theme.listMarker ?? theme.bodyText,
]
let topY = pos.baselineY - font.ascender
glyph.draw(at: CGPoint(x: pos.x, y: topY), withAttributes: glyphAttrs)
}
Expand Down Expand Up @@ -624,10 +628,14 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {
// char's font (0.1pt in a heading-first doc → 1px boxes).
let font = (textLayoutManager?.textContainer?.textView as? NativeTextView)?.baseFont
?? NSFont.systemFont(ofSize: NSFont.systemFontSize)
let configuration = (textLayoutManager?.textContainer?.textView as? NativeTextView)?.configuration
?? .default
let ascent = max(0, font.ascender)
let descent = max(0, -font.descender)
let size = TaskCheckboxGeometry.size(for: font)
let boxX = TaskCheckboxGeometry.boxX(contentX: pos.x, size: size)
let boxX = TaskCheckboxGeometry.boxX(
contentX: pos.x, size: size, markerTextGap: configuration.lists.markerTextGap
)
let centerY = pos.baselineY + (descent - ascent) / 2
let boxY = centerY - size / 2

Expand All @@ -641,8 +649,6 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {

let iconInset = max(0.0, size * 0.01)
let iconRect = boxRect.insetBy(dx: iconInset, dy: iconInset)
let configuration = (textLayoutManager?.textContainer?.textView as? NativeTextView)?.configuration
?? .default
let style = configuration.taskCheckbox
let symbolName = isChecked ? style.checkedSymbolName : style.uncheckedSymbolName
let fallbackName = isChecked
Expand Down
18 changes: 15 additions & 3 deletions Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,20 @@ enum TaskCheckboxGeometry {
return max(1.0, min(floor(fontHeight * 1.2), floor(markerWidth * 1.2)))
}

/// Left edge of the square: right-aligned to the content start x with `gap`.
static func boxX(contentX: CGFloat, size: CGFloat) -> CGFloat {
contentX - size - gap
/// Left edge of the square.
///
/// Legacy layout right-aligns the box to the content start x with `gap`
/// (the hidden `[ ] ` sits at the content edge because `- ` keeps full
/// advance). In the indent grid (``ListStyle/markerTextGap`` set) the
/// styler collapses the WHOLE `- [ ] `, so the box range's own position is
/// the marker-slot origin — the square is LEFT-aligned there, matching
/// where a bullet or number glyph would start. Gaps narrower than
/// `size + gap` let the square run into the content; configure
/// ``ListStyle/markerTextGap`` at least that wide.
static func boxX(contentX: CGFloat, size: CGFloat, markerTextGap: CGFloat? = nil) -> CGFloat {
if markerTextGap != nil {
return contentX
}
return contentX - size - gap
}
}
Loading