diff --git a/CHANGELOG.md b/CHANGELOG.md index dc542980..8c792e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 871d144c..e29fe744 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,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() @@ -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 } diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift index cc06a7f2..f524f3aa 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift @@ -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 @@ -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, @@ -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 diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index 9c0c5ea7..659b01c4 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) } @@ -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 @@ -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 diff --git a/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift b/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift index 294dec21..e1fcb0e2 100644 --- a/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift +++ b/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift @@ -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 } } diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift index e76cacb2..bf04ccb1 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] = [:] @@ -349,26 +416,46 @@ 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. + // STRUCTURAL depth (indent-ladder position), so 3-column ordered + // nesting picks the next style per parent, not per two source columns. + let markerStyle = ctx.config.lists.orderedMarkerStyle( + forDepth: ctx.listDepths[item.marker.location] ?? 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. 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 { 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) + 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 @@ -377,25 +464,69 @@ 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 = 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 = 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. + // (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 { 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), @@ -422,12 +553,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)) @@ -512,6 +644,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. @@ -548,9 +683,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 +699,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/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/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)) + } + } +} diff --git a/Tests/MarkdownEngineTests/ListIndentGridTests.swift b/Tests/MarkdownEngineTests/ListIndentGridTests.swift new file mode 100644 index 00000000..adeabc38 --- /dev/null +++ b/Tests/MarkdownEngineTests/ListIndentGridTests.swift @@ -0,0 +1,208 @@ +// +// 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 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") + 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: 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: 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) + } +} diff --git a/Tests/MarkdownEngineTests/ListMarkerStylingTests.swift b/Tests/MarkdownEngineTests/ListMarkerStylingTests.swift new file mode 100644 index 00000000..afd375b8 --- /dev/null +++ b/Tests/MarkdownEngineTests/ListMarkerStylingTests.swift @@ -0,0 +1,149 @@ +// +// 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("depth for the style is structural — a 3-column-per-level ordered ladder reaches roman on level 3") + func structuralDepthReachesRomanOnLevelThree() { + // CommonMark ordered nesting indents by the parent MARKER width (three + // columns), so the third level sits at six columns. A spaces/2 depth + // would read that as depth 3 and cycle back to numeric; the structural + // ladder reads depth 2 → roman. + let text = "1. one\n 1. two\n 1. three\n" + let attrs = style(text, styles: [.numeric, .lowerAlpha, .lowerRoman]) + let ns = text as NSString + #expect(overlayMarker(in: attrs, at: ns.range(of: "1. two").location) == "a.") + #expect(overlayMarker(in: attrs, at: ns.range(of: "1. three").location) == "i.") + } + + @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) + } +}