From 4c5ee23e4fda77fd895c7acd0a3eb19d6ad2a07f Mon Sep 17 00:00:00 2001 From: Dylan de Heer Date: Mon, 3 Aug 2026 22:59:26 +0200 Subject: [PATCH 1/4] Add optional heading typeface and heading text color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theme can recolor every element except heading text (it inherits bodyText), and the heading config covers every metric except the face — so an embedder that wants serif display headings over a sans body has to fork. HeadingStyle.fontName picks a PostScript face for headings, honored exactly so the chosen weight survives; an unresolvable name falls back to the stock bold base font, like TaskCheckboxStyle's symbol fallback. MarkdownEditorTheme.headingText colors heading text while the `#` glyphs stay on headingMarker and inline constructs inside a heading keep their own ink. Emphasis inside a heading still composes on top of the configured face (traits added, family and per-level size kept). Both knobs default to nil, which keeps styling identical to before. Co-authored-by: Cursor --- CHANGELOG.md | 9 + .../MarkdownEditorConfiguration.swift | 15 ++ .../Configuration/MarkdownEditorTheme.swift | 11 ++ .../Styling/MarkdownASTStyler.swift | 22 ++- .../HeadingFontAndColorTests.swift | 179 ++++++++++++++++++ 5 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 Tests/MarkdownEngineTests/HeadingFontAndColorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index dc542980..0d6c98ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- 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 diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 871d144c..983af71b 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -349,15 +349,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 } diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift index cc06a7f2..ff87b81a 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift @@ -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 @@ -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, @@ -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 diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift index e76cacb2..22a98a86 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift @@ -548,9 +548,15 @@ enum MarkdownASTStyler { case .heading(let level, let range, let markers, let inlines): let multiplier = ctx.config.headings.fontMultiplier(for: level) - let headingBase = NSFont(name: ctx.fontName, size: ctx.baseFont.pointSize * multiplier) - ?? .systemFont(ofSize: ctx.baseFont.pointSize * multiplier) - let headingFont = adding(.bold, to: headingBase) + let headingSize = ctx.baseFont.pointSize * multiplier + // A configured heading face is honored exactly — its weight is the + // embedder's choice, so no synthetic bold on top. A name that + // doesn't resolve degrades to the stock heading font (base family, + // bold trait), mirroring TaskCheckboxStyle's symbol fallback. + let headingFont = ctx.config.headings.fontName + .flatMap { NSFont(name: $0, size: headingSize) } + ?? adding(.bold, to: NSFont(name: ctx.fontName, size: headingSize) + ?? .systemFont(ofSize: headingSize)) let lineHeight = ceil(headingFont.ascender - headingFont.descender + headingFont.leading) + 1 let headingPara = NSMutableParagraphStyle() headingPara.minimumLineHeight = lineHeight @@ -558,7 +564,15 @@ enum MarkdownASTStyler { headingPara.paragraphSpacingBefore = headingFont.pointSize * ctx.config.headings.topSpacingEm(for: level) headingPara.paragraphSpacing = ctx.baseParagraphSpacing attrs.append((ctx.ns.paragraphRange(for: range), [.paragraphStyle: headingPara])) - attrs.append((range, [.font: headingFont])) + // theme.headingText paints the whole heading line; the marker loop + // and the inline descent below both append LATER, so `#` glyphs + // keep headingMarker and links / code keep their own ink — the + // same later-range-wins layering the bodyText default relies on. + var headingAttrs: [NSAttributedString.Key: Any] = [.font: headingFont] + if let headingText = ctx.theme.headingText { + headingAttrs[.foregroundColor] = headingText + } + attrs.append((range, headingAttrs)) for marker in markers { attrs.append((marker, [.foregroundColor: ctx.theme.headingMarker])) } diff --git a/Tests/MarkdownEngineTests/HeadingFontAndColorTests.swift b/Tests/MarkdownEngineTests/HeadingFontAndColorTests.swift new file mode 100644 index 00000000..bb8a8acb --- /dev/null +++ b/Tests/MarkdownEngineTests/HeadingFontAndColorTests.swift @@ -0,0 +1,179 @@ +// +// HeadingFontAndColorTests.swift +// MarkdownEngineTests +// +// The two opt-in heading knobs: `HeadingStyle.fontName` (a dedicated heading +// typeface) and `MarkdownEditorTheme.headingText` (a dedicated heading text +// color). Both default to nil, which must keep the stock styling unchanged — +// headings derive from the base font with the bold trait and inherit the +// view-level bodyText foreground. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Heading font & color knobs") +struct HeadingFontAndColorTests { + + private let base: CGFloat = 14 + private var fontName: String { NSFont.systemFont(ofSize: 14).fontName } + + /// A real, always-installed face that differs from the system font in both + /// family and weight, so assertions can see it was used verbatim. + private let headingFace = "Menlo-Regular" + + /// Effective font at `pos`: the last styled range covering it that sets `.font`. + private func font(in attrs: [StyledRange], at pos: Int) -> NSFont? { + var result: NSFont? + for (range, a) in attrs where NSLocationInRange(pos, range) { + if let f = a[.font] as? NSFont { result = f } + } + return result + } + + /// Effective color at `pos`: the last styled range covering it that sets `.foregroundColor`. + private func color(in attrs: [StyledRange], at pos: Int) -> NSColor? { + var result: NSColor? + for (range, a) in attrs where NSLocationInRange(pos, range) { + if let c = a[.foregroundColor] as? NSColor { result = c } + } + return result + } + + private func style( + _ text: String, + configuration: MarkdownEditorConfiguration = .default + ) -> [StyledRange] { + MarkdownASTStyler.styleAttributes( + text: text, fontName: fontName, fontSize: base, configuration: configuration + ) + } + + // MARK: - HeadingStyle.fontName + + @Test("headings.fontName renders headings in that face at the multiplied size") + func headingFontNameUsedVerbatimAtMultipliedSize() { + let config = MarkdownEditorConfiguration(headings: HeadingStyle(fontName: headingFace)) + // "# One\n\nbody\n\n## Two": O=2, b=7, T=16 + let attrs = style("# One\n\nbody\n\n## Two", configuration: config) + + let h1 = font(in: attrs, at: 2) + #expect(h1?.fontName == headingFace) + #expect(h1?.pointSize == base * 2.0) + // The face is honored exactly: no synthetic bold on the chosen weight. + #expect(h1?.fontDescriptor.symbolicTraits.contains(.bold) == false) + + // Per-level multipliers still apply to the custom face. + let h2 = font(in: attrs, at: 16) + #expect(h2?.fontName == headingFace) + #expect(h2?.pointSize == base * 1.5) + + // Body text never takes the heading face (no .font range at all). + #expect(font(in: attrs, at: 7) == nil) + } + + @Test("emphasis inside a custom-face heading keeps family and size, adds traits") + func emphasisComposesOnTheCustomHeadingFace() { + let config = MarkdownEditorConfiguration(headings: HeadingStyle(fontName: headingFace)) + // "# **n*o*des**": n=4, o=6, d=8 + let attrs = style("# **n*o*des**", configuration: config) + let n = font(in: attrs, at: 4) + let o = font(in: attrs, at: 6) + let d = font(in: attrs, at: 8) + + #expect(n?.familyName == "Menlo") + #expect(o?.familyName == "Menlo") + #expect(d?.familyName == "Menlo") + #expect(n?.pointSize == base * 2.0) + #expect(o?.pointSize == base * 2.0) + #expect(d?.pointSize == base * 2.0) + #expect(n?.fontDescriptor.symbolicTraits.contains(.bold) == true) + #expect(o?.fontDescriptor.symbolicTraits.contains([.bold, .italic]) == true) + #expect(d?.fontDescriptor.symbolicTraits.contains(.bold) == true) + } + + @Test("an unresolvable fontName falls back to the stock heading font") + func unresolvableFontNameFallsBack() { + let config = MarkdownEditorConfiguration( + headings: HeadingStyle(fontName: "Not-A-Real-Font-Face") + ) + let stock = font(in: style("# Title"), at: 2) + let fallback = font(in: style("# Title", configuration: config), at: 2) + #expect(fallback == stock) + #expect(fallback?.fontDescriptor.symbolicTraits.contains(.bold) == true) + } + + // MARK: - MarkdownEditorTheme.headingText + + @Test("theme.headingText colors heading text; # markers and body keep their own ink") + func headingTextColorsContentOnly() { + var theme = MarkdownEditorTheme.default + theme.headingText = .systemPink + let config = MarkdownEditorConfiguration(theme: theme) + // "# Title\n\nbody": marker=0..1, T=2, b=8 + let attrs = style("# Title\n\nbody", configuration: config) + + #expect(color(in: attrs, at: 2) == .systemPink) + // The `#` marker glyphs stay on headingMarker (the separate knob). + #expect(color(in: attrs, at: 0) == theme.headingMarker) + // Body text still inherits the view-level bodyText (no styled foreground). + #expect(color(in: attrs, at: 8) == nil) + } + + @Test("a link inside a colored heading keeps the link ink") + func linkInsideColoredHeadingKeepsLinkColor() { + var theme = MarkdownEditorTheme.default + theme.headingText = .systemPink + let config = MarkdownEditorConfiguration(theme: theme) + // "# [x](https://e.com)": x=3 + let attrs = style("# [x](https://e.com)", configuration: config) + #expect(color(in: attrs, at: 3) == theme.link) + } + + @Test("emphasis inside a colored heading keeps the heading color") + func emphasisInsideColoredHeadingKeepsHeadingColor() { + var theme = MarkdownEditorTheme.default + theme.headingText = .systemPink + let config = MarkdownEditorConfiguration(theme: theme) + // "# **bold**": b=4 — emphasis composes fonts only, so the ink survives. + let attrs = style("# **bold**", configuration: config) + #expect(color(in: attrs, at: 4) == .systemPink) + } + + // MARK: - Defaults stay byte-identical + + @Test("nil knobs: heading content carries the stock font and no foreground") + func nilKnobsKeepStockHeadingAttributes() { + // "# Title": T=2 + let attrs = style("# Title") + let heading = font(in: attrs, at: 2) + let stock = NSFont(name: fontName, size: base * 2.0) ?? .systemFont(ofSize: base * 2.0) + let stockBold = NSFont( + descriptor: stock.fontDescriptor.withSymbolicTraits( + stock.fontDescriptor.symbolicTraits.union(.bold)), + size: stock.pointSize + ) ?? stock + #expect(heading == stockBold) + // No styled range sets a heading foreground — bodyText inheritance. + #expect(color(in: attrs, at: 2) == nil) + } + + @Test("explicit-nil knobs produce value-identical styling to .default") + func nilKnobsMatchDefaultsExactly() { + let doc = "# One **bold** *i*\n\nbody `code`\n\n## Two\n\n- item\n\n> quote\n" + let expected = style(doc) + let explicitNil = MarkdownEditorConfiguration( + theme: MarkdownEditorTheme(headingText: nil), + headings: HeadingStyle(fontName: nil) + ) + let actual = style(doc, configuration: explicitNil) + + #expect(actual.count == expected.count) + for (a, e) in zip(actual, expected) { + #expect(a.range == e.range) + #expect((a.attributes as NSDictionary).isEqual(to: e.attributes)) + } + } +} From 4487df39bf057a74f3f79d7d271cd5ea9d1bb7bb Mon Sep 17 00:00:00 2001 From: Dylan de Heer Date: Tue, 4 Aug 2026 12:05:41 +0200 Subject: [PATCH 2/4] Add an opt-in list indent grid via ListStyle.markerTextGap Historically the visual list 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 (two spaces = ~8pt -- far off any design grid), and the marker-to-content gap is whatever '- ' happens to measure per marker kind. With markerTextGap set, markers land on a deterministic grid: a level-n item starts at n x indentPerLevel (level 1 on the body origin), the leading whitespace collapses (hidden-marker font; tabs advance by a sub-point interval), and content hangs a fixed slot after the marker for every marker kind via a kern on the final spacer char -- so bullet, any digit count, and task content all align, wrapped lines hang at the content edge, and the caret keeps working on real text advances. The drawn task checkbox left-aligns to the slot origin in grid mode. nil (the default) keeps the historical geometry exactly. Co-authored-by: Cursor --- CHANGELOG.md | 7 + .../MarkdownEditorConfiguration.swift | 31 ++- .../Renderer/MarkdownTextLayoutFragment.swift | 8 +- .../Renderer/TaskCheckboxGeometry.swift | 15 +- .../Styling/MarkdownASTStyler.swift | 51 ++++- .../NativeTextView+TaskCheckbox.swift | 5 +- .../ListIndentGridTests.swift | 182 ++++++++++++++++++ 7 files changed, 282 insertions(+), 17 deletions(-) create mode 100644 Tests/MarkdownEngineTests/ListIndentGridTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6c98ed..183635d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ 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), diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 983af71b..e8fe2f3d 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -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() diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index 9c0c5ea7..5e5dee52 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -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 @@ -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 diff --git a/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift b/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift index 294dec21..d37f5968 100644 --- a/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift +++ b/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift @@ -28,8 +28,17 @@ 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`. + /// In the indent grid (``ListStyle/markerTextGap`` set) the box is + /// LEFT-aligned to the marker slot's origin instead, matching where a + /// bullet or number would start — clamped so a slot narrower than the + /// box itself falls back to the right-aligned position. + static func boxX(contentX: CGFloat, size: CGFloat, markerTextGap: CGFloat? = nil) -> CGFloat { + if let markerTextGap { + return contentX - max(markerTextGap, size + gap) + } + return contentX - size - gap } } diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift index 22a98a86..5206e713 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift @@ -377,15 +377,48 @@ enum MarkdownASTStyler { ps.paragraphSpacing = ctx.baseParagraphSpacing ps.paragraphSpacingBefore = 0 ps.tabStops = [] - ps.defaultTabInterval = ctx.config.lists.indentPerLevel - ps.firstLineHeadIndent = ctx.config.lists.indentPerLevel - // Wrapped lines hang under the first line's content (indent + marker - // width). No checkbox-specific extra: the box is a drawn overlay that - // doesn't change text advance, so adding it here (and only here, not to - // firstLineHeadIndent) shifted an unchecked task's wrapped lines right - // of its first line. - ps.headIndent = ctx.config.lists.indentPerLevel + depthIndent + markerWidth - attrs.append((line, [.paragraphStyle: ps])) + if let gap = ctx.config.lists.markerTextGap, + item.contentRange.location - 1 >= NSMaxRange(item.marker) { + // Indent GRID (opt-in, see ListStyle.markerTextGap): the marker + // starts at depth × indentPerLevel — level 1 on the body origin — + // and content hangs a fixed slot after it. The raw source + // whitespace is neutralized below, so tabs must stop advancing to + // indentPerLevel stops; a sub-point interval reduces each tab to + // layout noise instead. + ps.defaultTabInterval = 0.25 + ps.firstLineHeadIndent = depthIndent + // The slot can widen freely but only narrow until the final spacer + // char would reach a negative advance (content folding back over + // the marker glyphs). + let spacerRange = NSRange(location: item.contentRange.location - 1, length: 1) + let spacerWidth = HeadingHelpers.textWidth(ctx.ns.substring(with: spacerRange), font: ctx.baseFont) + let slot = max(gap, markerWidth - spacerWidth + 0.5) + ps.headIndent = depthIndent + slot + attrs.append((line, [.paragraphStyle: ps])) + // Collapse the leading whitespace so the SOURCE indent stops being + // the visual indent — the paragraph indent above owns it now. + // (Tabs keep their sub-point interval advance; spaces take the + // hidden-marker font like every other collapsed syntax char.) + if wsRange.length > 0 { + attrs.append((wsRange, [.font: ctx.inlineMarkerFont])) + } + // Land the content exactly on the slot edge by kerning the final + // spacer char. markerWidth already measures the DISPLAY marker + // while the ordered overlay is active and the raw marker in every + // reveal state, so the same correction holds for every marker kind + // — content doesn't jump when the caret enters/leaves the syntax. + attrs.append((spacerRange, [.kern: slot - markerWidth])) + } else { + ps.defaultTabInterval = ctx.config.lists.indentPerLevel + ps.firstLineHeadIndent = ctx.config.lists.indentPerLevel + // Wrapped lines hang under the first line's content (indent + marker + // width). No checkbox-specific extra: the box is a drawn overlay that + // doesn't change text advance, so adding it here (and only here, not to + // firstLineHeadIndent) shifted an unchecked task's wrapped lines right + // of its first line. + ps.headIndent = ctx.config.lists.indentPerLevel + depthIndent + markerWidth + attrs.append((line, [.paragraphStyle: ps])) + } // 2. Marker decoration (suppressed while the caret edits the syntax). if let box = item.checkbox { diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift index d17f55cf..72b8b3ba 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift @@ -31,7 +31,10 @@ extension NativeTextView { guard let isChecked = value as? Bool else { return } let anchor = bridge.boundingRect(forCharacterRange: attrRange, in: textContainer) let rect = CGRect( - x: TaskCheckboxGeometry.boxX(contentX: anchor.minX, size: boxSize), + x: TaskCheckboxGeometry.boxX( + contentX: anchor.minX, size: boxSize, + markerTextGap: configuration.lists.markerTextGap + ), y: anchor.minY, width: boxSize, height: max(anchor.height, boxSize) diff --git a/Tests/MarkdownEngineTests/ListIndentGridTests.swift b/Tests/MarkdownEngineTests/ListIndentGridTests.swift new file mode 100644 index 00000000..1543b605 --- /dev/null +++ b/Tests/MarkdownEngineTests/ListIndentGridTests.swift @@ -0,0 +1,182 @@ +// +// ListIndentGridTests.swift +// MarkdownEngineTests +// +// The opt-in list indent grid (`ListStyle.markerTextGap`): markers on a +// deterministic depth × indentPerLevel grid with level 1 on the body origin, +// the raw source whitespace neutralized, and content hanging a fixed slot +// after the marker. `nil` (the default) must keep the historical geometry +// bit-for-bit. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("List indent grid (ListStyle.markerTextGap)") +struct ListIndentGridTests { + + private let base: CGFloat = 16 + private var fontName: String { NSFont.systemFont(ofSize: 16).fontName } + private var baseFont: NSFont { NSFont.systemFont(ofSize: 16) } + + private func gridConfig(gap: CGFloat = 36, indent: CGFloat = 24) -> MarkdownEditorConfiguration { + MarkdownEditorConfiguration(lists: ListStyle(indentPerLevel: indent, markerTextGap: gap)) + } + + private func style(_ text: String, _ config: MarkdownEditorConfiguration = .default) -> [StyledRange] { + MarkdownASTStyler.styleAttributes(text: text, fontName: fontName, fontSize: base, configuration: config) + } + + /// Effective paragraph style at `pos` (last styled range wins). + private func paragraphStyle(in attrs: [StyledRange], at pos: Int) -> NSParagraphStyle? { + var result: NSParagraphStyle? + for (range, a) in attrs where NSLocationInRange(pos, range) { + if let p = a[.paragraphStyle] as? NSParagraphStyle { result = p } + } + return result + } + + /// Effective kern at `pos`, if any styled range sets one. + private func kern(in attrs: [StyledRange], at pos: Int) -> CGFloat? { + var result: CGFloat? + for (range, a) in attrs where NSLocationInRange(pos, range) { + if let k = a[.kern] as? CGFloat { result = k } + } + return result + } + + /// Effective font at `pos` (last styled range wins). + private func font(in attrs: [StyledRange], at pos: Int) -> NSFont? { + var result: NSFont? + for (range, a) in attrs where NSLocationInRange(pos, range) { + if let f = a[.font] as? NSFont { result = f } + } + return result + } + + // MARK: - Default preserved + + @Test("nil markerTextGap keeps the historical flat first-line indent and raw-whitespace nesting") + func nilGapKeepsLegacyGeometry() { + let text = "- top\n - nested\n" + let attrs = style(text) + let perLevel = MarkdownEditorConfiguration.default.lists.indentPerLevel + + let top = paragraphStyle(in: attrs, at: 0) + #expect(top?.firstLineHeadIndent == perLevel) + #expect(top?.defaultTabInterval == perLevel) + let markerWidth = HeadingHelpers.textWidth("- ", font: baseFont) + #expect(abs((top?.headIndent ?? 0) - (perLevel + markerWidth)) < 0.01) + + // Nested: first line stays FLAT (raw whitespace is the visual indent), + // only the wrapped-line hang includes the depth. + let nested = paragraphStyle(in: attrs, at: 8) + #expect(nested?.firstLineHeadIndent == perLevel) + #expect(abs((nested?.headIndent ?? 0) - (perLevel + perLevel + markerWidth)) < 0.01) + + // No kern correction and no whitespace collapse in legacy mode. + #expect(kern(in: attrs, at: 1) == nil) + #expect(font(in: attrs, at: 6)?.pointSize == nil || font(in: attrs, at: 6)?.pointSize == base) + } + + // MARK: - Grid geometry + + @Test("level 1 marker sits on the body origin; content hangs at the gap") + func levelOneOnBodyOrigin() { + let text = "- alpha beta\n" + let attrs = style(text, gridConfig()) + let ps = paragraphStyle(in: attrs, at: 0) + #expect(ps?.firstLineHeadIndent == 0) + #expect(ps?.headIndent == 36) + + // The final spacer char (before "alpha") is kerned so the marker→content + // advance lands exactly on the slot. + let markerWidth = HeadingHelpers.textWidth("- ", font: baseFont) + let k = kern(in: attrs, at: 1) + #expect(k != nil) + #expect(abs((k ?? 0) - (36 - markerWidth)) < 0.01) + } + + @Test("nesting steps by indentPerLevel and collapses the source whitespace") + func nestedStepsByIndentPerLevel() { + let text = "- top\n - two\n - three\n" + let attrs = style(text, gridConfig()) + let ns = text as NSString + + let two = paragraphStyle(in: attrs, at: ns.range(of: "- two").location) + #expect(two?.firstLineHeadIndent == 24) + #expect(abs((two?.headIndent ?? 0) - (24 + 36)) < 0.01) + + let three = paragraphStyle(in: attrs, at: ns.range(of: "- three").location) + #expect(three?.firstLineHeadIndent == 48) + #expect(abs((three?.headIndent ?? 0) - (48 + 36)) < 0.01) + + // The two leading spaces collapse to the hidden-marker font so the + // source indent stops shifting the line. + let hidden = MarkdownEditorConfiguration.default.markers.hiddenMarkerFontSize + #expect(font(in: attrs, at: 6)?.pointSize == hidden) + } + + @Test("tab-indented items land on the same grid; tabs advance by a sub-point interval") + func tabIndentedItemsUseGrid() { + let text = "- top\n\t- nested\n" + let attrs = style(text, gridConfig()) + let ns = text as NSString + let nested = paragraphStyle(in: attrs, at: ns.range(of: "- nested").location) + #expect(nested?.firstLineHeadIndent == 24) + #expect(nested?.defaultTabInterval == 0.25) + } + + @Test("ordered markers share the same slot so bullet and numbered content align") + func orderedMarkersShareTheSlot() { + let text = "1. first\n2. second\n" + let attrs = style(text, gridConfig()) + let ps = paragraphStyle(in: attrs, at: 0) + #expect(ps?.firstLineHeadIndent == 0) + #expect(ps?.headIndent == 36) + + let markerWidth = HeadingHelpers.textWidth("1. ", font: baseFont) + let k = kern(in: attrs, at: 2) // the space between "1." and "first" + #expect(k != nil) + #expect(abs((k ?? 0) - (36 - markerWidth)) < 0.01) + } + + @Test("task items keep the grid geometry") + func taskItemsKeepGridGeometry() { + let text = "- [ ] task content\n" + let attrs = style(text, gridConfig()) + let ps = paragraphStyle(in: attrs, at: 0) + #expect(ps?.firstLineHeadIndent == 0) + #expect(ps?.headIndent == 36) + } + + @Test("a slot narrower than the marker widens just enough to keep the spacer advance positive") + func narrowSlotClamps() { + let text = "10. wide marker\n" + let attrs = style(text, gridConfig(gap: 4)) + let markerWidth = HeadingHelpers.textWidth("10. ", font: baseFont) + let spacerWidth = HeadingHelpers.textWidth(" ", font: baseFont) + let expectedSlot = markerWidth - spacerWidth + 0.5 + let ps = paragraphStyle(in: attrs, at: 0) + #expect(abs((ps?.headIndent ?? 0) - expectedSlot) < 0.01) + let k = kern(in: attrs, at: 3) + #expect(abs((k ?? 0) - (expectedSlot - markerWidth)) < 0.01) + } + + // MARK: - Checkbox slot + + @Test("the drawn checkbox left-aligns to the marker slot in grid mode and right-aligns otherwise") + func checkboxAlignmentPerMode() { + // Legacy: right-aligned to the content edge with the fixed gap. + #expect(TaskCheckboxGeometry.boxX(contentX: 100, size: 17) == 100 - 17 - TaskCheckboxGeometry.gap) + // Grid: left-aligned to the slot origin. + #expect(TaskCheckboxGeometry.boxX(contentX: 100, size: 17, markerTextGap: 36) == 64) + // A slot narrower than the box falls back to the right-aligned position. + #expect( + TaskCheckboxGeometry.boxX(contentX: 100, size: 17, markerTextGap: 10) + == 100 - 17 - TaskCheckboxGeometry.gap + ) + } +} From 94101ea4d2af9715a25eee43a7a9ad64d4312a8a Mon Sep 17 00:00:00 2001 From: Dylan de Heer Date: Tue, 4 Aug 2026 12:08:57 +0200 Subject: [PATCH 3/4] Add per-depth ordered marker lettering and a list-marker ink slot ListStyle.orderedMarkerStyles formats each nesting depth's painted ordered marker (numeric / alpha / roman, cycling), so nested runs can render 1. / a. / i. while the source keeps its literal digits -- the same source-untouched contract display numbering already follows. A non-numeric depth keeps the overlay active even when the computed number matches the source, since '1.' still has to display as 'a.'; caret and selection reveal the raw digits exactly as before. The default (a single .numeric) keeps every level numeric. MarkdownEditorTheme.listMarker colors the painted bullet and ordered marker glyphs independently of bodyText; nil (the default) keeps the historical body ink. Co-authored-by: Cursor --- CHANGELOG.md | 7 + .../MarkdownEditorConfiguration.swift | 75 +++++++++- .../Configuration/MarkdownEditorTheme.swift | 8 ++ .../Renderer/MarkdownTextLayoutFragment.swift | 8 +- .../Styling/MarkdownASTStyler.swift | 15 +- .../ListMarkerStylingTests.swift | 136 ++++++++++++++++++ 6 files changed, 242 insertions(+), 7 deletions(-) create mode 100644 Tests/MarkdownEngineTests/ListMarkerStylingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6c98ed..910f20ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ 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). - 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), diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 983af71b..352eadf9 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -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, @@ -299,19 +357,34 @@ 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] public init( helpersEnabled: Bool = true, autoClosePairsEnabled: Bool = true, indentPerLevel: CGFloat = 27.5, maximumNestingLevel: Int = 3, - extraLineHeight: CGFloat = 2 + extraLineHeight: CGFloat = 2, + orderedMarkerStyles: [OrderedMarkerStyle] = [.numeric] ) { self.helpersEnabled = helpersEnabled self.autoClosePairsEnabled = autoClosePairsEnabled self.indentPerLevel = indentPerLevel self.maximumNestingLevel = maximumNestingLevel self.extraLineHeight = extraLineHeight + self.orderedMarkerStyles = orderedMarkerStyles + } + + /// 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() diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift index ff87b81a..f524f3aa 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift @@ -47,6 +47,12 @@ public struct MarkdownEditorTheme: Sendable { 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 @@ -96,6 +102,7 @@ public struct MarkdownEditorTheme: Sendable { disabledText: NSColor = .tertiaryLabelColor, headingText: NSColor? = nil, headingMarker: NSColor = .gray, + listMarker: NSColor? = nil, link: NSColor = .linkColor, incompleteLink: NSColor = .systemBlue, findMatchHighlight: NSColor = .systemYellow, @@ -110,6 +117,7 @@ public struct MarkdownEditorTheme: Sendable { self.disabledText = disabledText self.headingText = headingText self.headingMarker = headingMarker + self.listMarker = listMarker self.link = link self.incompleteLink = incompleteLink self.findMatchHighlight = findMatchHighlight diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index 9c0c5ea7..f83ea031 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -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 @@ -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) } diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift index 22a98a86..fae417c2 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift @@ -349,11 +349,17 @@ enum MarkdownASTStyler { // tasks (the checkbox branch owns those). let orderedSyntax = NSRange(location: item.marker.location, length: item.contentRange.location - item.marker.location) + // The per-depth marker style (1. / a. / i. — .numeric everywhere by + // default). Only the painted overlay changes; the source stays digits. + let markerStyle = ctx.config.lists.orderedMarkerStyle(forDepth: MarkdownLists.indentLevel(from: ws)) // Also off while the marker is inside a selection: the painter reveals the // raw source digits there, so the slot must revert to raw width (else a // kerned slot leaves a gap/overlap over the raw digits). + // A non-numeric style keeps the overlay on even when the computed number + // MATCHES the source digits — `1.` still has to display as `a.`. let orderedOverlayActive = item.ordered && item.checkbox == nil && item.number != nil - && displayNumber != nil && displayNumber != item.number + && displayNumber != nil + && (displayNumber != item.number || markerStyle != .numeric) && !MarkdownStyler.caretRevealsOrderedMarker(caret: ctx.caret, syntax: orderedSyntax) && !ctx.selectionIntersects(orderedSyntax) // Keep the source punctuation (`.` or `)`) when overlaying, so a paren list stays a paren list. @@ -364,7 +370,7 @@ enum MarkdownASTStyler { if orderedOverlayActive, let displayNumber { let gap = ctx.ns.substring(with: NSRange(location: NSMaxRange(item.marker), length: item.contentRange.location - NSMaxRange(item.marker))) - return HeadingHelpers.textWidth("\(displayNumber)\(orderedPunct)" + gap, font: ctx.baseFont) + return HeadingHelpers.textWidth(markerStyle.label(for: displayNumber) + orderedPunct + gap, font: ctx.baseFont) } return HeadingHelpers.textWidth(ctx.ns.substring(with: markerGroup), font: ctx.baseFont) }() @@ -422,12 +428,13 @@ enum MarkdownASTStyler { // content baseline down under the pinned line height); spread across // all marker chars so every glyph advance stays positive even when // the number shrinks (10 → 9). + let display = markerStyle.label(for: displayNumber) + orderedPunct let sourceW = (ctx.ns.substring(with: item.marker) as NSString) .size(withAttributes: [.font: ctx.baseFont]).width - let displayW = ("\(displayNumber)\(orderedPunct)" as NSString) + let displayW = (display as NSString) .size(withAttributes: [.font: ctx.baseFont]).width var markerAttrs: [NSAttributedString.Key: Any] = [ - .orderedMarker: "\(displayNumber)\(orderedPunct)", .foregroundColor: NSColor.clear, + .orderedMarker: display, .foregroundColor: NSColor.clear, ] if abs(displayW - sourceW) > 0.01 { markerAttrs[.kern] = (displayW - sourceW) / CGFloat(max(1, item.marker.length)) diff --git a/Tests/MarkdownEngineTests/ListMarkerStylingTests.swift b/Tests/MarkdownEngineTests/ListMarkerStylingTests.swift new file mode 100644 index 00000000..5b878bd6 --- /dev/null +++ b/Tests/MarkdownEngineTests/ListMarkerStylingTests.swift @@ -0,0 +1,136 @@ +// +// ListMarkerStylingTests.swift +// MarkdownEngineTests +// +// Per-depth ordered marker styles (`ListStyle.orderedMarkerStyles`) and the +// `MarkdownEditorTheme.listMarker` ink slot. Lettering only changes the +// painted overlay — the source digits stay untouched — and the default +// (single `.numeric`) must keep today's rendering exactly. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("List marker styling") +struct ListMarkerStylingTests { + + private let base: CGFloat = 16 + private var fontName: String { NSFont.systemFont(ofSize: 16).fontName } + + private func style( + _ text: String, + styles: [OrderedMarkerStyle] = [.numeric], + caret: Int = -1 + ) -> [StyledRange] { + MarkdownASTStyler.styleAttributes( + text: text, fontName: fontName, fontSize: base, caretLocation: caret, + configuration: MarkdownEditorConfiguration(lists: ListStyle(orderedMarkerStyles: styles)) + ) + } + + /// The painted overlay marker covering `pos`, if the overlay is active. + private func overlayMarker(in attrs: [StyledRange], at pos: Int) -> String? { + var result: String? + for (range, a) in attrs where NSLocationInRange(pos, range) { + if let m = a[.orderedMarker] as? String { result = m } + } + return result + } + + // MARK: - Label formatting + + @Test("alpha labels count bijective base-26") + func alphaLabels() { + let style = OrderedMarkerStyle.lowerAlpha + #expect(style.label(for: 1) == "a") + #expect(style.label(for: 2) == "b") + #expect(style.label(for: 26) == "z") + #expect(style.label(for: 27) == "aa") + #expect(style.label(for: 28) == "ab") + #expect(style.label(for: 53) == "ba") + #expect(OrderedMarkerStyle.upperAlpha.label(for: 27) == "AA") + } + + @Test("roman labels use subtractive notation") + func romanLabels() { + let style = OrderedMarkerStyle.lowerRoman + #expect(style.label(for: 1) == "i") + #expect(style.label(for: 3) == "iii") + #expect(style.label(for: 4) == "iv") + #expect(style.label(for: 9) == "ix") + #expect(style.label(for: 14) == "xiv") + #expect(style.label(for: 40) == "xl") + #expect(OrderedMarkerStyle.upperRoman.label(for: 4) == "IV") + } + + @Test("numeric labels are the digits; non-positive numbers fall back to digits") + func numericAndFallbackLabels() { + #expect(OrderedMarkerStyle.numeric.label(for: 7) == "7") + #expect(OrderedMarkerStyle.lowerAlpha.label(for: 0) == "0") + } + + @Test("styles cycle per depth; an empty array reads as numeric") + func stylesCyclePerDepth() { + let lists = ListStyle(orderedMarkerStyles: [.numeric, .lowerAlpha, .lowerRoman]) + #expect(lists.orderedMarkerStyle(forDepth: 0) == .numeric) + #expect(lists.orderedMarkerStyle(forDepth: 1) == .lowerAlpha) + #expect(lists.orderedMarkerStyle(forDepth: 2) == .lowerRoman) + #expect(lists.orderedMarkerStyle(forDepth: 3) == .numeric) + #expect(ListStyle(orderedMarkerStyles: []).orderedMarkerStyle(forDepth: 2) == .numeric) + } + + // MARK: - Overlay behavior + + @Test("the default stays numeric: a matching source number paints no overlay") + func defaultNumericPaintsNoOverlayForMatchingSource() { + // "1." and "2." already display what the source says — the overlay + // stays off, exactly as before this knob existed. + let text = "1. one\n2. two\n" + let attrs = style(text) + #expect(overlayMarker(in: attrs, at: 0) == nil) + #expect(overlayMarker(in: attrs, at: 7) == nil) + + // A repeated literal still renumbers by position (existing behavior). + let repeated = style("1. one\n1. two\n") + #expect(overlayMarker(in: repeated, at: 7) == "2.") + } + + @Test("a non-numeric depth letters the marker even when the number matches the source") + func letteringActivatesOverlayAtNestedDepth() { + let text = "1. top\n 1. child\n" + let attrs = style(text, styles: [.numeric, .lowerAlpha]) + let ns = text as NSString + let childMarker = ns.range(of: "1. child").location + #expect(overlayMarker(in: attrs, at: 0) == nil, "top level stays numeric with no overlay") + #expect(overlayMarker(in: attrs, at: childMarker) == "a.") + } + + @Test("lettering keeps the source punctuation — a paren list stays a paren list") + func letteringKeepsParenPunctuation() { + let text = "1) top\n 1) child\n" + let attrs = style(text, styles: [.numeric, .lowerAlpha]) + let ns = text as NSString + let childMarker = ns.range(of: "1) child").location + #expect(overlayMarker(in: attrs, at: childMarker) == "a)") + } + + @Test("the caret inside the marker reveals the raw digits (overlay off)") + func caretRevealsRawDigits() { + let text = "1. top\n 1. child\n" + let ns = text as NSString + let childMarker = ns.range(of: "1. child").location + let attrs = style(text, styles: [.numeric, .lowerAlpha], caret: childMarker + 1) + #expect(overlayMarker(in: attrs, at: childMarker) == nil) + } + + // MARK: - Theme slot + + @Test("theme.listMarker defaults to nil and carries a custom ink") + func listMarkerThemeSlot() { + #expect(MarkdownEditorTheme.default.listMarker == nil) + let muted = NSColor(calibratedWhite: 0.5, alpha: 1) + #expect(MarkdownEditorTheme(listMarker: muted).listMarker == muted) + } +} From 09bd9c30eb8c1d73d5a28ff9ca2fe03a447c4412 Mon Sep 17 00:00:00 2001 From: Dylan de Heer Date: Tue, 4 Aug 2026 12:53:35 +0200 Subject: [PATCH 4/4] Use structural list depth for the indent grid and left-align the drawn checkbox to its marker slot Two fixes to the opt-in indent grid observed in a real render: - Grid depth now comes from the item's position in the run's indent ladder (seeded across scoped-restyle windows like the ordered display numbers) instead of a fixed 2-columns-per-level divisor, so CommonMark ordered nesting (3+ columns per level, driven by the parent marker width) steps exactly one level per parent. Legacy (nil markerTextGap) geometry is untouched. - A hidden task in the grid now collapses its whole `- [ ] ` prefix so the box range's own position is the marker-slot origin, and the drawn square left-aligns there (previously the square was offset from the box chars, which sit AFTER the full-advance `- `, pushing it to negative x where the container clipped it). Co-authored-by: Cursor --- .../Renderer/TaskCheckboxGeometry.swift | 17 ++- .../Styling/MarkdownASTStyler.swift | 124 +++++++++++++++--- .../ListIndentGridTests.swift | 44 +++++-- 3 files changed, 153 insertions(+), 32 deletions(-) diff --git a/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift b/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift index d37f5968..e1fcb0e2 100644 --- a/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift +++ b/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift @@ -30,14 +30,17 @@ enum TaskCheckboxGeometry { /// Left edge of the square. /// - /// Legacy layout right-aligns the box to the content start x with `gap`. - /// In the indent grid (``ListStyle/markerTextGap`` set) the box is - /// LEFT-aligned to the marker slot's origin instead, matching where a - /// bullet or number would start — clamped so a slot narrower than the - /// box itself falls back to the right-aligned position. + /// 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 let markerTextGap { - return contentX - max(markerTextGap, size + gap) + if markerTextGap != nil { + return contentX } return contentX - size - gap } diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift index 5206e713..788b31a3 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift @@ -71,7 +71,8 @@ enum MarkdownASTStyler { extensionsByID: configuration.extensionsByID, wikiLinkID: wikiLinkIDProvider, scopedRanges: scopedRanges, - orderedDisplayNumbers: computeOrderedDisplayNumbers(blocks: blocks, ns: ns) + orderedDisplayNumbers: computeOrderedDisplayNumbers(blocks: blocks, ns: ns), + listDepths: computeListDepths(blocks: blocks, ns: ns) ) var attrs: [StyledRange] = [] for block in blocks where ctx.inScope(block.range) { @@ -209,12 +210,13 @@ enum MarkdownASTStyler { /// hole between two scoped blocks" without materializing the substring. private static let nonWhitespace = CharacterSet.whitespacesAndNewlines.inverted - /// Replays the ordered-list run that continues ABOVE `loc` (scanning backward - /// in the full source: same-indent items counted, blank lines skipped, real - /// content stops it) and returns the next number per indent. Lets a scoped - /// restyle that only sees a local window continue the document's numbering. - private static func seedOrderedCounters(above loc: Int, in ns: NSString) -> [Int: Int] { - guard loc > 0, loc <= ns.length else { return [:] } + /// The list-item lines of the run that continues ABOVE `loc`, bottom-to-top + /// (scanning backward in the full source: list lines collected, blank lines + /// skipped, real content stops the scan). `number` is nil for bullets/tasks. + /// Shared by the ordered-counter and indent-stack seeds so a scoped restyle + /// that only sees a local window can rebuild the document-level run state. + private static func scanListRunLines(above loc: Int, in ns: NSString) -> [(indent: Int, number: Int?)] { + guard loc > 0, loc <= ns.length else { return [] } var runLines: [(indent: Int, number: Int?)] = [] // bottom-to-top; nil = bullet/other list // From the START of loc's line: callers pass a MARKER offset, which for // an indented item still sits inside its own line — scanning up from @@ -240,8 +242,16 @@ enum MarkdownASTStyler { runLines.append(((ws as NSString).length, number)) scan = lineRange.location } + return runLines + } + + /// Replays the ordered-list run that continues ABOVE `loc` (scanning backward + /// in the full source: same-indent items counted, blank lines skipped, real + /// content stops it) and returns the next number per indent. Lets a scoped + /// restyle that only sees a local window continue the document's numbering. + private static func seedOrderedCounters(above loc: Int, in ns: NSString) -> [Int: Int] { var counters: [Int: Int] = [:] - for item in runLines.reversed() { // replay top-to-bottom + for item in scanListRunLines(above: loc, in: ns).reversed() { // replay top-to-bottom if let number = item.number { counters[item.indent] = (counters[item.indent] ?? number) + 1 } else { @@ -252,6 +262,63 @@ enum MarkdownASTStyler { return counters } + /// Replays the list run that continues ABOVE `loc` and returns the stack of + /// open ancestor indent columns (outermost first) — the state + /// `computeListDepths` needs to keep a nested item's depth stable when a + /// scoped restyle starts mid-list. + private static func seedIndentStack(above loc: Int, in ns: NSString) -> [Int] { + var stack: [Int] = [] + for item in scanListRunLines(above: loc, in: ns).reversed() { // replay top-to-bottom + while let top = stack.last, top >= item.indent { stack.removeLast() } + stack.append(item.indent) + } + return stack + } + + /// Structural nesting depth (0-based) per list item, keyed by marker + /// location. Derived from the ORDER of indent columns in the run — an item + /// is one level deeper than the nearest earlier item with a smaller indent + /// — instead of a fixed spaces-per-level divisor, so ordered nesting (3+ + /// columns per level, marker-width driven) and bullet nesting (2 columns) + /// land on the same ladder. Mirrors `computeOrderedDisplayNumbers`' + /// gap/seed handling so scoped restyles see document-level depth. + private static func computeListDepths(blocks: [BlockNode], ns: NSString) -> [Int: Int] { + var result: [Int: Int] = [:] + var stack: [Int] = [] // open ancestor indent columns + var needsSeed = true + var contiguousEnd: Int? + for block in blocks { + if let contiguousEnd, block.range.location > contiguousEnd, + ns.rangeOfCharacter(from: Self.nonWhitespace, options: [], + range: NSRange(location: contiguousEnd, + length: block.range.location - contiguousEnd)).location != NSNotFound { + stack = [] + needsSeed = true + } + contiguousEnd = NSMaxRange(block.range) + switch block { + case .list(_, let items): + for item in items { + if needsSeed { + stack = seedIndentStack(above: item.marker.location, in: ns) + needsSeed = false + } + while let top = stack.last, top >= item.indent { stack.removeLast() } + result[item.marker.location] = stack.count + stack.append(item.indent) + } + case .blank: + break // blank lines keep the run (spacing, not a reset) + case .paragraph(_, let inlines) where inlines.isEmpty: + break // an empty paragraph line is spacing too + default: + stack = [] // real text/content ends the run + needsSeed = false // a seed scan would stop on this line anyway + } + } + return result + } + private static func computeOrderedDisplayNumbers(blocks: [BlockNode], ns: NSString) -> [Int: Int] { var result: [Int: Int] = [:] var counters: [Int: Int] = [:] @@ -359,6 +426,10 @@ enum MarkdownASTStyler { // Keep the source punctuation (`.` or `)`) when overlaying, so a paren list stays a paren list. let orderedPunct = orderedOverlayActive && item.marker.length > 0 ? ctx.ns.substring(with: NSRange(location: NSMaxRange(item.marker) - 1, length: 1)) : "." + // A hidden task in the indent grid collapses its `- ` too (the drawn box + // replaces the whole marker slot, LEFT-aligned at the slot origin), so + // the slot kern below must measure the marker at its collapsed advance. + let gridHiddenTask = ctx.config.lists.markerTextGap != nil && item.checkbox != nil && !taskRevealed // Via the memoized measure — list markers are a tiny repeated set (`- `, `1. `). let markerWidth: CGFloat = { if orderedOverlayActive, let displayNumber { @@ -366,9 +437,16 @@ enum MarkdownASTStyler { length: item.contentRange.location - NSMaxRange(item.marker))) return HeadingHelpers.textWidth("\(displayNumber)\(orderedPunct)" + gap, font: ctx.baseFont) } - return HeadingHelpers.textWidth(ctx.ns.substring(with: markerGroup), font: ctx.baseFont) + return HeadingHelpers.textWidth(ctx.ns.substring(with: markerGroup), + font: gridHiddenTask ? ctx.inlineMarkerFont : ctx.baseFont) }() + // Legacy layout derives depth from a fixed 2-columns-per-level divisor; + // the grid uses the STRUCTURAL depth (position of this item's indent in + // the run's indent ladder) so 3-column ordered nesting steps one level + // per parent, not one per two source columns. let depthIndent = CGFloat(MarkdownLists.indentLevel(from: ws)) * ctx.config.lists.indentPerLevel + let structuralDepth = ctx.listDepths[item.marker.location] ?? MarkdownLists.indentLevel(from: ws) + let gridDepthIndent = CGFloat(structuralDepth) * ctx.config.lists.indentPerLevel let ps = NSMutableParagraphStyle() let lineHeight = ctx.baseLineHeight + ctx.config.lists.extraLineHeight ps.minimumLineHeight = lineHeight @@ -386,14 +464,14 @@ enum MarkdownASTStyler { // indentPerLevel stops; a sub-point interval reduces each tab to // layout noise instead. ps.defaultTabInterval = 0.25 - ps.firstLineHeadIndent = depthIndent + ps.firstLineHeadIndent = gridDepthIndent // The slot can widen freely but only narrow until the final spacer // char would reach a negative advance (content folding back over // the marker glyphs). let spacerRange = NSRange(location: item.contentRange.location - 1, length: 1) let spacerWidth = HeadingHelpers.textWidth(ctx.ns.substring(with: spacerRange), font: ctx.baseFont) let slot = max(gap, markerWidth - spacerWidth + 0.5) - ps.headIndent = depthIndent + slot + ps.headIndent = gridDepthIndent + slot attrs.append((line, [.paragraphStyle: ps])) // Collapse the leading whitespace so the SOURCE indent stops being // the visual indent — the paragraph indent above owns it now. @@ -424,11 +502,22 @@ enum MarkdownASTStyler { if let box = item.checkbox { if taskRevealed { return } let spacer = NSRange(location: NSMaxRange(item.marker), length: box.location - NSMaxRange(item.marker)) - // `- ` keeps full advance (the box's slot, like the bullet `•`); - // `[ ]` + trailing space collapse to the hidden-marker font so the - // content starts at the bullet-content x. - attrs.append((item.marker, [.foregroundColor: NSColor.clear])) - if spacer.length > 0 { attrs.append((spacer, [.foregroundColor: NSColor.clear])) } + if gridHiddenTask { + // Indent grid: the WHOLE `- [ ] ` collapses so the box range's + // position IS the marker-slot origin — the drawn square sits + // LEFT-aligned there (where a bullet glyph would start) and the + // slot kern above already measures the collapsed marker. + attrs.append((item.marker, [.foregroundColor: NSColor.clear, .font: ctx.inlineMarkerFont])) + if spacer.length > 0 { + attrs.append((spacer, [.foregroundColor: NSColor.clear, .font: ctx.inlineMarkerFont])) + } + } else { + // `- ` keeps full advance (the box's slot, like the bullet `•`); + // `[ ]` + trailing space collapse to the hidden-marker font so the + // content starts at the bullet-content x. + attrs.append((item.marker, [.foregroundColor: NSColor.clear])) + if spacer.length > 0 { attrs.append((spacer, [.foregroundColor: NSColor.clear])) } + } attrs.append((box, [.taskCheckbox: item.checked, .foregroundColor: NSColor.clear, .font: ctx.inlineMarkerFont])) let postGap = NSRange(location: NSMaxRange(box), @@ -545,6 +634,9 @@ enum MarkdownASTStyler { let wikiLinkID: (NSRange) -> String? let scopedRanges: [NSRange]? let orderedDisplayNumbers: [Int: Int] + /// Structural nesting depth (0-based) per list item, keyed by marker + /// location — see `computeListDepths`. + let listDepths: [Int: Int] /// True when a non-empty selection overlaps `range` — the selection /// counterpart of `isActive` for elements that reveal on select. diff --git a/Tests/MarkdownEngineTests/ListIndentGridTests.swift b/Tests/MarkdownEngineTests/ListIndentGridTests.swift index 1543b605..adeabc38 100644 --- a/Tests/MarkdownEngineTests/ListIndentGridTests.swift +++ b/Tests/MarkdownEngineTests/ListIndentGridTests.swift @@ -143,13 +143,42 @@ struct ListIndentGridTests { #expect(abs((k ?? 0) - (36 - markerWidth)) < 0.01) } - @Test("task items keep the grid geometry") + @Test("task items keep the grid geometry and collapse the whole `- [ ] ` marker") func taskItemsKeepGridGeometry() { let text = "- [ ] task content\n" let attrs = style(text, gridConfig()) let ps = paragraphStyle(in: attrs, at: 0) #expect(ps?.firstLineHeadIndent == 0) #expect(ps?.headIndent == 36) + + // The `- ` collapses too (the drawn box owns the slot, left-aligned at + // its origin), so the slot kern measures the collapsed marker and the + // content still lands on the slot edge. + let hidden = MarkdownEditorConfiguration.default.markers.hiddenMarkerFontSize + #expect(font(in: attrs, at: 0)?.pointSize == hidden) // "-" + #expect(font(in: attrs, at: 1)?.pointSize == hidden) // " " + let collapsedFont = font(in: attrs, at: 0) ?? baseFont + let collapsedWidth = HeadingHelpers.textWidth("- ", font: collapsedFont) + let k = kern(in: attrs, at: 5) // the space between "]" and "task" + #expect(k != nil) + #expect(abs((k ?? 0) - (36 - collapsedWidth)) < 0.01) + } + + @Test("ordered nesting steps one level per parent, not one per two source columns") + func orderedNestingUsesStructuralDepth() { + // CommonMark ordered nesting indents by the parent MARKER width — three + // columns here. The naive spaces/2 divisor would put "three" on level 3 + // (72pt); the structural ladder keeps it on level 2 (48pt). + let text = "1. one\n 1. two\n 1. three\n" + let attrs = style(text, gridConfig()) + let ns = text as NSString + + let two = paragraphStyle(in: attrs, at: ns.range(of: "1. two").location) + #expect(two?.firstLineHeadIndent == 24) + + let three = paragraphStyle(in: attrs, at: ns.range(of: "1. three").location) + #expect(three?.firstLineHeadIndent == 48) + #expect(abs((three?.headIndent ?? 0) - (48 + 36)) < 0.01) } @Test("a slot narrower than the marker widens just enough to keep the spacer advance positive") @@ -169,14 +198,11 @@ struct ListIndentGridTests { @Test("the drawn checkbox left-aligns to the marker slot in grid mode and right-aligns otherwise") func checkboxAlignmentPerMode() { - // Legacy: right-aligned to the content edge with the fixed gap. + // Legacy: the hidden `[ ] ` sits at the content edge (the `- ` keeps + // full advance), so the square right-aligns to it with the fixed gap. #expect(TaskCheckboxGeometry.boxX(contentX: 100, size: 17) == 100 - 17 - TaskCheckboxGeometry.gap) - // Grid: left-aligned to the slot origin. - #expect(TaskCheckboxGeometry.boxX(contentX: 100, size: 17, markerTextGap: 36) == 64) - // A slot narrower than the box falls back to the right-aligned position. - #expect( - TaskCheckboxGeometry.boxX(contentX: 100, size: 17, markerTextGap: 10) - == 100 - 17 - TaskCheckboxGeometry.gap - ) + // Grid: the whole `- [ ] ` collapses, so the box range's own position + // IS the marker-slot origin — the square draws right there. + #expect(TaskCheckboxGeometry.boxX(contentX: 24, size: 17, markerTextGap: 36) == 24) } }