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

## [Unreleased]

### Added
- Opt-in list indent grid: `ListStyle.markerTextGap` puts list markers on a
deterministic `depth × indentPerLevel` grid (level 1 aligned with the body
origin), 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 @@ -299,19 +299,48 @@ 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
/// 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,
markerTextGap: CGFloat? = nil
) {
self.helpersEnabled = helpersEnabled
self.autoClosePairsEnabled = autoClosePairsEnabled
self.indentPerLevel = indentPerLevel
self.maximumNestingLevel = maximumNestingLevel
self.extraLineHeight = extraLineHeight
self.markerTextGap = markerTextGap
}

public static let `default` = ListStyle()
Expand Down Expand Up @@ -349,15 +378,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
11 changes: 11 additions & 0 deletions Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ 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

Expand Down Expand Up @@ -85,6 +94,7 @@ public struct MarkdownEditorTheme: Sendable {
bodyText: NSColor = .labelColor,
mutedText: NSColor = .secondaryLabelColor,
disabledText: NSColor = .tertiaryLabelColor,
headingText: NSColor? = nil,
headingMarker: NSColor = .gray,
link: NSColor = .linkColor,
incompleteLink: NSColor = .systemBlue,
Expand All @@ -98,6 +108,7 @@ public struct MarkdownEditorTheme: Sendable {
self.bodyText = bodyText
self.mutedText = mutedText
self.disabledText = disabledText
self.headingText = headingText
self.headingMarker = headingMarker
self.link = link
self.incompleteLink = incompleteLink
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -624,10 +624,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 +645,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