From 3e95c5c128578ff7b8c6d4592f28b1dd79be4513 Mon Sep 17 00:00:00 2001 From: Luis Kisters Date: Tue, 11 Aug 2026 15:34:02 +0200 Subject: [PATCH 1/4] feat(lists): configurable bullet shapes and task checkbox style --- .../MarkdownEditorConfiguration.swift | 78 ++++++++++++- .../Renderer/MarkdownTextLayoutFragment.swift | 108 ++++++++++++++---- .../Renderer/TaskCheckboxGeometry.swift | 4 + .../Styling/MarkdownASTStyler.swift | 6 +- .../NativeTextView+TaskCheckbox.swift | 2 +- .../TextView/NativeTextViewWrapper.swift | 5 + .../MarkdownASTStylerTests.swift | 31 +++++ 7 files changed, 207 insertions(+), 27 deletions(-) diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 0ce6261b..6089a2c5 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -270,6 +270,76 @@ public struct InlineCodeStyle: Sendable { // MARK: - Lists +public enum BulletShape: Sendable, Equatable { + case filledDot + case hollowRing + case smallSquare + case triangle + case glyph(String) +} + +public struct BulletStyle: Sendable, Equatable { + /// Index 0 is depth 1; the last entry repeats for deeper levels. + public var shapeLadder: [BulletShape] + /// nil uses `theme.bodyText`. + public var color: NSColor? + /// Multiplier on the drawn shape's diameter. + public var sizeScale: CGFloat + + public init( + shapeLadder: [BulletShape] = [.filledDot], + color: NSColor? = nil, + sizeScale: CGFloat = 1 + ) { + self.shapeLadder = shapeLadder + self.color = color + self.sizeScale = sizeScale + } + + public static let `default` = BulletStyle() + public static let tiered = BulletStyle(shapeLadder: [.filledDot, .hollowRing, .smallSquare, .triangle]) + + /// 1-based depth -> shape, clamped; empty ladder -> `.filledDot`. + public func shape(forDepth depth: Int) -> BulletShape { + guard !shapeLadder.isEmpty else { return .filledDot } + return shapeLadder[min(max(depth, 1), shapeLadder.count) - 1] + } +} + +public struct TaskCheckboxStyle: Sendable, Equatable { + /// nil derives the size from the font. + public var size: CGFloat? + public var strokeWidth: CGFloat + public var cornerRadius: CGFloat + /// nil uses `theme.mutedText`. + public var uncheckedColor: NSColor? + /// nil uses `theme.bodyText`. + public var checkedFillColor: NSColor? + /// nil uses `NSColor.white`; the theme has no background color. + public var checkmarkColor: NSColor? + + public init( + size: CGFloat? = nil, + strokeWidth: CGFloat = 1, + cornerRadius: CGFloat = 3, + uncheckedColor: NSColor? = nil, + checkedFillColor: NSColor? = nil, + checkmarkColor: NSColor? = nil + ) { + self.size = size + self.strokeWidth = strokeWidth + self.cornerRadius = cornerRadius + self.uncheckedColor = uncheckedColor + self.checkedFillColor = checkedFillColor + self.checkmarkColor = checkmarkColor + } + + public static let `default` = TaskCheckboxStyle() + + /// True when nothing is customised, so the renderer keeps the SF Symbol path. + public var usesSystemSymbol: Bool { self == .default } +} + /// Behavior toggles and metrics for ordered / unordered list editing. public struct ListStyle: Sendable { /// Master switch for list-related editing helpers (auto-continue, @@ -284,19 +354,25 @@ 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 + public var bullets: BulletStyle + public var taskCheckbox: TaskCheckboxStyle public init( helpersEnabled: Bool = true, autoClosePairsEnabled: Bool = true, indentPerLevel: CGFloat = 27.5, maximumNestingLevel: Int = 3, - extraLineHeight: CGFloat = 2 + extraLineHeight: CGFloat = 2, + bullets: BulletStyle = .default, + taskCheckbox: TaskCheckboxStyle = .default ) { self.helpersEnabled = helpersEnabled self.autoClosePairsEnabled = autoClosePairsEnabled self.indentPerLevel = indentPerLevel self.maximumNestingLevel = maximumNestingLevel self.extraLineHeight = extraLineHeight + self.bullets = bullets + self.taskCheckbox = taskCheckbox } public static let `default` = ListStyle() diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index 3184dcd2..9a00a938 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -24,6 +24,7 @@ extension NSAttributedString.Key { /// Marks a bullet-list marker char (`-`/`*`/`+`) whose glyph is hidden so /// the fragment can paint a `•` in its place. Set to `true`. static let bulletMarker = NSAttributedString.Key("BulletListMarker") + static let bulletListLevel = NSAttributedString.Key("BulletListLevel") /// CGFloat — natural image width; presence flags block as overlay-rendered. static let scrollableBlockNaturalWidth = NSAttributedString.Key("ScrollableBlockNaturalWidth") /// Int — hash of source text; key for overlay reconcile + offset persistence. @@ -524,8 +525,9 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { let nsContext = NSGraphicsContext(cgContext: context, flipped: true) NSGraphicsContext.current = nsContext - let theme = (textLayoutManager?.textContainer?.textView as? NativeTextView)? - .configuration.theme ?? .default + let configuration = (textLayoutManager?.textContainer?.textView as? NativeTextView)?.configuration + let theme = configuration?.theme ?? .default + let style = configuration?.lists.bullets ?? .default let storageString = ts.string as NSString ts.enumerateAttribute(.bulletMarker, in: range, options: []) { [weak self] value, attrRange, _ in @@ -536,16 +538,51 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { let font = (ts.attribute(.font, at: attrRange.location, effectiveRange: nil) as? NSFont) ?? (self.textLayoutManager?.textContainer?.textView?.font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize)) - let bulletAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: theme.bodyText] - let bullet = "•" as NSString - let markerWidth = storageString.substring(with: attrRange).size(withAttributes: [.font: font]).width - let bulletWidth = bullet.size(withAttributes: bulletAttrs).width - let xOffset = max(0, (markerWidth - bulletWidth) / 2) - // Flipped context: text origin is its top edge, baseline sits one - // ascent below — so top = baseline − ascent aligns the glyph. - let topY = pos.baselineY - font.ascender - bullet.draw(at: CGPoint(x: pos.x + xOffset, y: topY), withAttributes: bulletAttrs) + let level = ts.attribute(.bulletListLevel, at: attrRange.location, effectiveRange: nil) as? Int ?? 1 + let shape = style.shape(forDepth: level) + switch shape { + case .filledDot, .glyph: + let bulletAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: style.color ?? theme.bodyText] + let bullet: NSString + if case let .glyph(glyph) = shape { + bullet = glyph as NSString + } else { + bullet = "•" as NSString + } + let bulletWidth = bullet.size(withAttributes: bulletAttrs).width + let xOffset = max(0, (markerWidth - bulletWidth) / 2) + // Flipped context: text origin is its top edge, baseline sits one + // ascent below — so top = baseline − ascent aligns the glyph. + let topY = pos.baselineY - font.ascender + bullet.draw(at: CGPoint(x: pos.x + xOffset, y: topY), withAttributes: bulletAttrs) + case .hollowRing, .smallSquare, .triangle: + let diameter = round(font.pointSize * 0.32 * style.sizeScale) + let center = CGPoint( + x: pos.x + markerWidth / 2, + y: pos.baselineY + (max(0, -font.descender) - max(0, font.ascender)) / 2 + ) + let rect = CGRect(x: center.x - diameter / 2, y: center.y - diameter / 2, + width: diameter, height: diameter) + (style.color ?? theme.bodyText).set() + switch shape { + case .hollowRing: + let path = NSBezierPath(ovalIn: rect) + path.lineWidth = 1 + path.stroke() + case .smallSquare: + NSBezierPath(rect: rect).fill() + case .triangle: + let path = NSBezierPath() + path.move(to: CGPoint(x: center.x, y: rect.minY)) + path.line(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.line(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.close() + path.fill() + default: + break + } + } } } @@ -562,6 +599,8 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { defer { NSGraphicsContext.restoreGraphicsState() } let nsContext = NSGraphicsContext(cgContext: context, flipped: true) NSGraphicsContext.current = nsContext + let textView = textLayoutManager?.textContainer?.textView as? NativeTextView + let style = textView?.configuration.lists.taskCheckbox ?? .default ts.enumerateAttribute(.taskCheckbox, in: range, options: []) { [weak self] value, attrRange, _ in guard let self, value != nil else { return } @@ -578,7 +617,7 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { ?? NSFont.systemFont(ofSize: NSFont.systemFontSize) let ascent = max(0, font.ascender) let descent = max(0, -font.descender) - let size = TaskCheckboxGeometry.size(for: font) + let size = TaskCheckboxGeometry.size(for: font, style: style) let boxX = TaskCheckboxGeometry.boxX(contentX: pos.x, size: size) let centerY = pos.baselineY + (descent - ascent) / 2 let boxY = centerY - size / 2 @@ -591,17 +630,40 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { let boxRect = CGRect(x: alignToPixel(boxX), y: alignToPixel(boxY), width: size, height: size) guard !boxRect.isEmpty, !boxRect.isNull else { return } - let iconInset = max(0.0, size * 0.01) - let iconRect = boxRect.insetBy(dx: iconInset, dy: iconInset) - let symbolName = isChecked ? "checkmark.square.fill" : "square" - if let baseSymbol = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) { - let sizeConfig = NSImage.SymbolConfiguration(pointSize: iconRect.height, weight: .regular) - let theme = (textLayoutManager?.textContainer?.textView as? NativeTextView)?.configuration.theme ?? .default - let tint = isChecked ? theme.bodyText : theme.mutedText - let colorConfig = NSImage.SymbolConfiguration(hierarchicalColor: tint) - let symbolConfig = sizeConfig.applying(colorConfig) - let symbol = baseSymbol.withSymbolConfiguration(symbolConfig) ?? baseSymbol - symbol.draw(in: iconRect) + if style.usesSystemSymbol { + let iconInset = max(0.0, size * 0.01) + let iconRect = boxRect.insetBy(dx: iconInset, dy: iconInset) + let symbolName = isChecked ? "checkmark.square.fill" : "square" + if let baseSymbol = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) { + let sizeConfig = NSImage.SymbolConfiguration(pointSize: iconRect.height, weight: .regular) + let theme = (textLayoutManager?.textContainer?.textView as? NativeTextView)?.configuration.theme ?? .default + let tint = isChecked ? theme.bodyText : theme.mutedText + let colorConfig = NSImage.SymbolConfiguration(hierarchicalColor: tint) + let symbolConfig = sizeConfig.applying(colorConfig) + let symbol = baseSymbol.withSymbolConfiguration(symbolConfig) ?? baseSymbol + symbol.draw(in: iconRect) + } + } else { + let theme = textView?.configuration.theme ?? .default + let box = NSBezierPath(roundedRect: boxRect, xRadius: style.cornerRadius, + yRadius: style.cornerRadius) + if isChecked { + (style.checkedFillColor ?? theme.bodyText).setFill() + box.fill() + let check = NSBezierPath() + check.lineWidth = style.strokeWidth + check.lineCapStyle = .round + check.lineJoinStyle = .round + check.move(to: CGPoint(x: boxRect.minX + size * 0.23, y: boxRect.minY + size * 0.52)) + check.line(to: CGPoint(x: boxRect.minX + size * 0.43, y: boxRect.minY + size * 0.72)) + check.line(to: CGPoint(x: boxRect.minX + size * 0.78, y: boxRect.minY + size * 0.30)) + (style.checkmarkColor ?? NSColor.white).setStroke() + check.stroke() + } else { + box.lineWidth = style.strokeWidth + (style.uncheckedColor ?? theme.mutedText).setStroke() + box.stroke() + } } } } diff --git a/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift b/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift index 294dec21..06233d21 100644 --- a/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift +++ b/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift @@ -28,6 +28,10 @@ enum TaskCheckboxGeometry { return max(1.0, min(floor(fontHeight * 1.2), floor(markerWidth * 1.2))) } + static func size(for font: NSFont, style: TaskCheckboxStyle) -> CGFloat { + style.size ?? size(for: font) + } + /// 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 diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift index 6dc5d7d1..bdc358d3 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift @@ -217,7 +217,8 @@ enum MarkdownASTStyler { } let markerWidth = (ctx.ns.substring(with: markerGroup) as NSString) .size(withAttributes: [.font: ctx.baseFont]).width - let depthIndent = CGFloat(MarkdownLists.indentLevel(from: ws)) * ctx.config.lists.indentPerLevel + let level = MarkdownLists.indentLevel(from: ws) + let depthIndent = CGFloat(level) * ctx.config.lists.indentPerLevel let ps = NSMutableParagraphStyle() let lineHeight = ctx.baseLineHeight + ctx.config.lists.extraLineHeight ps.minimumLineHeight = lineHeight @@ -262,7 +263,8 @@ enum MarkdownASTStyler { let syntax = NSRange(location: item.marker.location, length: item.contentRange.location - item.marker.location) if NSLocationInRange(ctx.caret, syntax) { return } - attrs.append((item.marker, [.bulletMarker: true, .foregroundColor: NSColor.clear])) + attrs.append((item.marker, [.bulletMarker: true, .bulletListLevel: level + 1, + .foregroundColor: NSColor.clear])) } } diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift index d17f55cf..a3c12f24 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift @@ -24,7 +24,7 @@ extension NativeTextView { guard let textContainer = textContainer, let bridge = layoutBridge, let storage = textStorage, storage.length > 0 else { return nil } - let boxSize = TaskCheckboxGeometry.size(for: baseFont) + let boxSize = TaskCheckboxGeometry.size(for: baseFont, style: configuration.lists.taskCheckbox) let scan = searchRange ?? NSRange(location: 0, length: storage.length) var hit: (range: NSRange, isChecked: Bool)? storage.enumerateAttribute(.taskCheckbox, in: scan, options: []) { value, attrRange, stop in diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index cfae673e..52637c5f 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -469,7 +469,12 @@ public struct NativeTextViewWrapper: NSViewRepresentable { // makeNSView used to write it — an embedder settings change was inert // until the editor was rebuilt. Plain assignment: a tiny value struct, // and no rebuild is needed for it to take effect. + let oldBulletStyle = textView.configuration.lists.bullets + let oldTaskCheckboxStyle = textView.configuration.lists.taskCheckbox textView.configuration.lists = configuration.lists + if oldBulletStyle != configuration.lists.bullets || oldTaskCheckboxStyle != configuration.lists.taskCheckbox { + textView.setNeedsDisplay(textView.visibleRect) + } context.coordinator.configuration.lists = configuration.lists // Sync registered extensions (inline spans + fenced blocks). A change alters the GRAMMAR // (tokens differ under the new registry), so the coordinator's parsed diff --git a/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift b/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift index 128480df..9ff70dbd 100644 --- a/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift +++ b/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift @@ -289,3 +289,34 @@ private func styleKeySnapshot(_ ranges: [StyledRange]) -> String { private func fmt(_ r: NSRange) -> String { r.location == NSNotFound ? "∅" : "\(r.location)+\(r.length)" } + +@Suite("List marker styles") +struct ListMarkerStyleTests { + + @Test("default styles preserve existing rendering") + func defaultStyles() { + #expect(ListStyle.default.bullets == .default) + #expect(TaskCheckboxStyle.default.usesSystemSymbol) + for depth in 1...5 { + #expect(BulletStyle.default.shape(forDepth: depth) == .filledDot) + } + } + + @Test("tiered bullet shapes clamp at the last entry") + func tieredShapes() { + #expect(BulletStyle.tiered.shape(forDepth: 1) == .filledDot) + #expect(BulletStyle.tiered.shape(forDepth: 2) == .hollowRing) + #expect(BulletStyle.tiered.shape(forDepth: 3) == .smallSquare) + #expect(BulletStyle.tiered.shape(forDepth: 4) == .triangle) + #expect(BulletStyle.tiered.shape(forDepth: 5) == .triangle) + #expect(BulletStyle(shapeLadder: []).shape(forDepth: 1) == .filledDot) + } + + @Test("styler records the top-level bullet depth") + func topLevelBulletDepth() { + let fontName = NSFont.systemFont(ofSize: 14).fontName + let attrs = MarkdownASTStyler.styleAttributes(text: "- a", fontName: fontName, fontSize: 14) + let marker = attrs.first { ($0.attributes[.bulletMarker] as? Bool) == true } + #expect(marker?.attributes[.bulletListLevel] as? Int == 1) + } +} From 046475beac6c2133edff7018c7c6c7007cfafcda Mon Sep 17 00:00:00 2001 From: Luis Kisters Date: Tue, 11 Aug 2026 16:12:20 +0200 Subject: [PATCH 2/4] refactor(lists): drop unused bullet knobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cut BulletShape.glyph, BulletStyle.sizeScale and BulletStyle.tiered — no embedder set them — and flatten the bullet draw switch. Co-Authored-By: Claude Opus 5 (1M context) --- .../MarkdownEditorConfiguration.swift | 8 +-- .../Renderer/MarkdownTextLayoutFragment.swift | 66 +++++++++---------- .../MarkdownASTStylerTests.swift | 18 ++--- 3 files changed, 39 insertions(+), 53 deletions(-) diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 6089a2c5..9f0ebb45 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -275,7 +275,6 @@ public enum BulletShape: Sendable, Equatable { case hollowRing case smallSquare case triangle - case glyph(String) } public struct BulletStyle: Sendable, Equatable { @@ -283,21 +282,16 @@ public struct BulletStyle: Sendable, Equatable { public var shapeLadder: [BulletShape] /// nil uses `theme.bodyText`. public var color: NSColor? - /// Multiplier on the drawn shape's diameter. - public var sizeScale: CGFloat public init( shapeLadder: [BulletShape] = [.filledDot], - color: NSColor? = nil, - sizeScale: CGFloat = 1 + color: NSColor? = nil ) { self.shapeLadder = shapeLadder self.color = color - self.sizeScale = sizeScale } public static let `default` = BulletStyle() - public static let tiered = BulletStyle(shapeLadder: [.filledDot, .hollowRing, .smallSquare, .triangle]) /// 1-based depth -> shape, clamped; empty ladder -> `.filledDot`. public func shape(forDepth depth: Int) -> BulletShape { diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index 9a00a938..69e4554c 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -540,48 +540,44 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { ?? (self.textLayoutManager?.textContainer?.textView?.font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize)) let markerWidth = storageString.substring(with: attrRange).size(withAttributes: [.font: font]).width let level = ts.attribute(.bulletListLevel, at: attrRange.location, effectiveRange: nil) as? Int ?? 1 + let color = style.color ?? theme.bodyText let shape = style.shape(forDepth: level) - switch shape { - case .filledDot, .glyph: - let bulletAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: style.color ?? theme.bodyText] - let bullet: NSString - if case let .glyph(glyph) = shape { - bullet = glyph as NSString - } else { - bullet = "•" as NSString - } + if shape == .filledDot { + let bulletAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: color] + let bullet = "•" as NSString let bulletWidth = bullet.size(withAttributes: bulletAttrs).width let xOffset = max(0, (markerWidth - bulletWidth) / 2) // Flipped context: text origin is its top edge, baseline sits one // ascent below — so top = baseline − ascent aligns the glyph. let topY = pos.baselineY - font.ascender bullet.draw(at: CGPoint(x: pos.x + xOffset, y: topY), withAttributes: bulletAttrs) - case .hollowRing, .smallSquare, .triangle: - let diameter = round(font.pointSize * 0.32 * style.sizeScale) - let center = CGPoint( - x: pos.x + markerWidth / 2, - y: pos.baselineY + (max(0, -font.descender) - max(0, font.ascender)) / 2 - ) - let rect = CGRect(x: center.x - diameter / 2, y: center.y - diameter / 2, - width: diameter, height: diameter) - (style.color ?? theme.bodyText).set() - switch shape { - case .hollowRing: - let path = NSBezierPath(ovalIn: rect) - path.lineWidth = 1 - path.stroke() - case .smallSquare: - NSBezierPath(rect: rect).fill() - case .triangle: - let path = NSBezierPath() - path.move(to: CGPoint(x: center.x, y: rect.minY)) - path.line(to: CGPoint(x: rect.maxX, y: rect.maxY)) - path.line(to: CGPoint(x: rect.minX, y: rect.maxY)) - path.close() - path.fill() - default: - break - } + return + } + + let diameter = round(font.pointSize * 0.32) + let center = CGPoint( + x: pos.x + markerWidth / 2, + y: pos.baselineY + (max(0, -font.descender) - max(0, font.ascender)) / 2 + ) + let rect = CGRect(x: center.x - diameter / 2, y: center.y - diameter / 2, + width: diameter, height: diameter) + color.set() + switch shape { + case .filledDot: + break + case .hollowRing: + let path = NSBezierPath(ovalIn: rect) + path.lineWidth = 1 + path.stroke() + case .smallSquare: + NSBezierPath(rect: rect).fill() + case .triangle: + let path = NSBezierPath() + path.move(to: CGPoint(x: center.x, y: rect.minY)) + path.line(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.line(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.close() + path.fill() } } } diff --git a/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift b/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift index 9ff70dbd..1c6950ce 100644 --- a/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift +++ b/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift @@ -293,22 +293,18 @@ private func fmt(_ r: NSRange) -> String { @Suite("List marker styles") struct ListMarkerStyleTests { + /// The default style must still hit the `•` glyph and SF Symbol paths. @Test("default styles preserve existing rendering") func defaultStyles() { - #expect(ListStyle.default.bullets == .default) + #expect(BulletStyle.default.shape(forDepth: 1) == .filledDot) #expect(TaskCheckboxStyle.default.usesSystemSymbol) - for depth in 1...5 { - #expect(BulletStyle.default.shape(forDepth: depth) == .filledDot) - } } - @Test("tiered bullet shapes clamp at the last entry") - func tieredShapes() { - #expect(BulletStyle.tiered.shape(forDepth: 1) == .filledDot) - #expect(BulletStyle.tiered.shape(forDepth: 2) == .hollowRing) - #expect(BulletStyle.tiered.shape(forDepth: 3) == .smallSquare) - #expect(BulletStyle.tiered.shape(forDepth: 4) == .triangle) - #expect(BulletStyle.tiered.shape(forDepth: 5) == .triangle) + @Test("bullet shapes clamp at the last ladder entry") + func ladderClamping() { + let style = BulletStyle(shapeLadder: [.filledDot, .hollowRing, .smallSquare]) + #expect(style.shape(forDepth: 2) == .hollowRing) + #expect(style.shape(forDepth: 9) == .smallSquare) #expect(BulletStyle(shapeLadder: []).shape(forDepth: 1) == .filledDot) } From 50965c4472e0e88819ae42297fa0f246c7bba331 Mon Sep 17 00:00:00 2001 From: Luis Kisters Date: Tue, 11 Aug 2026 19:53:55 +0200 Subject: [PATCH 3/4] feat(lists): configurable gap between checkbox and task content The gap was a fixed 2pt tuned for the font-derived SF Symbol box. A larger configured box needs a larger gap or the label reads as touching it. Both the draw site and the click hit-test take it from the style, so their rects still cannot drift. --- .../Configuration/MarkdownEditorConfiguration.swift | 5 +++++ .../MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift | 2 +- Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift | 2 +- .../NativeTextView/NativeTextView+TaskCheckbox.swift | 5 +++-- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 9f0ebb45..115b56bf 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -305,6 +305,9 @@ public struct TaskCheckboxStyle: Sendable, Equatable { public var size: CGFloat? public var strokeWidth: CGFloat public var cornerRadius: CGFloat + /// Gap between the box's right edge and the task content's left edge. A + /// larger box needs a larger gap or the label reads as touching it. + public var gap: CGFloat /// nil uses `theme.mutedText`. public var uncheckedColor: NSColor? /// nil uses `theme.bodyText`. @@ -316,6 +319,7 @@ public struct TaskCheckboxStyle: Sendable, Equatable { size: CGFloat? = nil, strokeWidth: CGFloat = 1, cornerRadius: CGFloat = 3, + gap: CGFloat = 2, uncheckedColor: NSColor? = nil, checkedFillColor: NSColor? = nil, checkmarkColor: NSColor? = nil @@ -323,6 +327,7 @@ public struct TaskCheckboxStyle: Sendable, Equatable { self.size = size self.strokeWidth = strokeWidth self.cornerRadius = cornerRadius + self.gap = gap self.uncheckedColor = uncheckedColor self.checkedFillColor = checkedFillColor self.checkmarkColor = checkmarkColor diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index 69e4554c..013a22d7 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -614,7 +614,7 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { let ascent = max(0, font.ascender) let descent = max(0, -font.descender) let size = TaskCheckboxGeometry.size(for: font, style: style) - let boxX = TaskCheckboxGeometry.boxX(contentX: pos.x, size: size) + let boxX = TaskCheckboxGeometry.boxX(contentX: pos.x, size: size, gap: style.gap) let centerY = pos.baselineY + (descent - ascent) / 2 let boxY = centerY - size / 2 diff --git a/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift b/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift index 06233d21..9c7326d9 100644 --- a/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift +++ b/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift @@ -33,7 +33,7 @@ enum TaskCheckboxGeometry { } /// Left edge of the square: right-aligned to the content start x with `gap`. - static func boxX(contentX: CGFloat, size: CGFloat) -> CGFloat { + static func boxX(contentX: CGFloat, size: CGFloat, gap: CGFloat = gap) -> CGFloat { contentX - size - gap } } diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift index a3c12f24..413673ad 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift @@ -24,14 +24,15 @@ extension NativeTextView { guard let textContainer = textContainer, let bridge = layoutBridge, let storage = textStorage, storage.length > 0 else { return nil } - let boxSize = TaskCheckboxGeometry.size(for: baseFont, style: configuration.lists.taskCheckbox) + let checkboxStyle = configuration.lists.taskCheckbox + let boxSize = TaskCheckboxGeometry.size(for: baseFont, style: checkboxStyle) let scan = searchRange ?? NSRange(location: 0, length: storage.length) var hit: (range: NSRange, isChecked: Bool)? storage.enumerateAttribute(.taskCheckbox, in: scan, options: []) { value, attrRange, stop in 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, gap: checkboxStyle.gap), y: anchor.minY, width: boxSize, height: max(anchor.height, boxSize) From 99389c79fa1ac1f4267496b8599ee4ac39bce615 Mon Sep 17 00:00:00 2001 From: Luis Kisters Date: Tue, 11 Aug 2026 20:00:53 +0200 Subject: [PATCH 4/4] refactor(lists): explicit checkbox rendering mode, configurable ring stroke usesSystemSymbol inferred the rendering path from `self == .default`, so an embedder could not ask for the drawn box at default metrics and any future field would silently re-route embedders between the two paths. Make it an explicit `rendering` mode instead. Also lift the ring's hardcoded stroke width into the style, document the bullet-level attribute key, and compare the whole ListStyle for the redisplay check so a later knob cannot be missed. Tests pin the 0-based indentLevel to 1-based ladder conversion across a real nested list, plus the checkbox size and gap geometry. --- .../MarkdownEditorConfiguration.swift | 22 +++++++++--- .../Renderer/MarkdownTextLayoutFragment.swift | 8 +++-- .../TextView/NativeTextViewWrapper.swift | 5 ++- .../MarkdownASTStylerTests.swift | 36 +++++++++++++++---- 4 files changed, 54 insertions(+), 17 deletions(-) diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 115b56bf..c54b0482 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -282,13 +282,17 @@ public struct BulletStyle: Sendable, Equatable { public var shapeLadder: [BulletShape] /// nil uses `theme.bodyText`. public var color: NSColor? + /// Stroke width of the `.hollowRing` shape. + public var ringStrokeWidth: CGFloat public init( shapeLadder: [BulletShape] = [.filledDot], - color: NSColor? = nil + color: NSColor? = nil, + ringStrokeWidth: CGFloat = 1 ) { self.shapeLadder = shapeLadder self.color = color + self.ringStrokeWidth = ringStrokeWidth } public static let `default` = BulletStyle() @@ -301,6 +305,15 @@ public struct BulletStyle: Sendable, Equatable { } public struct TaskCheckboxStyle: Sendable, Equatable { + /// How the box is painted. `.systemSymbol` is the historical rendering + /// (SF Symbols `square` / `checkmark.square.fill`) and ignores the stroke, + /// radius and colour fields below. + public enum Rendering: Sendable, Equatable { + case systemSymbol + case drawn + } + + public var rendering: Rendering /// nil derives the size from the font. public var size: CGFloat? public var strokeWidth: CGFloat @@ -316,6 +329,7 @@ public struct TaskCheckboxStyle: Sendable, Equatable { public var checkmarkColor: NSColor? public init( + rendering: Rendering = .systemSymbol, size: CGFloat? = nil, strokeWidth: CGFloat = 1, cornerRadius: CGFloat = 3, @@ -324,6 +338,7 @@ public struct TaskCheckboxStyle: Sendable, Equatable { checkedFillColor: NSColor? = nil, checkmarkColor: NSColor? = nil ) { + self.rendering = rendering self.size = size self.strokeWidth = strokeWidth self.cornerRadius = cornerRadius @@ -334,13 +349,10 @@ public struct TaskCheckboxStyle: Sendable, Equatable { } public static let `default` = TaskCheckboxStyle() - - /// True when nothing is customised, so the renderer keeps the SF Symbol path. - public var usesSystemSymbol: Bool { self == .default } } /// Behavior toggles and metrics for ordered / unordered list editing. -public struct ListStyle: Sendable { +public struct ListStyle: Sendable, Equatable { /// Master switch for list-related editing helpers (auto-continue, /// auto-indent, marker conversion). When `false`, lists are still /// rendered, but typing-time conveniences are skipped. diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index 013a22d7..e1ad977e 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -24,6 +24,8 @@ extension NSAttributedString.Key { /// Marks a bullet-list marker char (`-`/`*`/`+`) whose glyph is hidden so /// the fragment can paint a `•` in its place. Set to `true`. static let bulletMarker = NSAttributedString.Key("BulletListMarker") + /// Int nesting level (1-based) of a bullet-list marker; selects the shape + /// from `BulletStyle.shapeLadder`. Absent means depth 1. static let bulletListLevel = NSAttributedString.Key("BulletListLevel") /// CGFloat — natural image width; presence flags block as overlay-rendered. static let scrollableBlockNaturalWidth = NSAttributedString.Key("ScrollableBlockNaturalWidth") @@ -564,10 +566,10 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { color.set() switch shape { case .filledDot: - break + break // Unreachable: the glyph path above already returned. case .hollowRing: let path = NSBezierPath(ovalIn: rect) - path.lineWidth = 1 + path.lineWidth = style.ringStrokeWidth path.stroke() case .smallSquare: NSBezierPath(rect: rect).fill() @@ -626,7 +628,7 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { let boxRect = CGRect(x: alignToPixel(boxX), y: alignToPixel(boxY), width: size, height: size) guard !boxRect.isEmpty, !boxRect.isNull else { return } - if style.usesSystemSymbol { + if style.rendering == .systemSymbol { let iconInset = max(0.0, size * 0.01) let iconRect = boxRect.insetBy(dx: iconInset, dy: iconInset) let symbolName = isChecked ? "checkmark.square.fill" : "square" diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index 52637c5f..c51e868d 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -469,10 +469,9 @@ public struct NativeTextViewWrapper: NSViewRepresentable { // makeNSView used to write it — an embedder settings change was inert // until the editor was rebuilt. Plain assignment: a tiny value struct, // and no rebuild is needed for it to take effect. - let oldBulletStyle = textView.configuration.lists.bullets - let oldTaskCheckboxStyle = textView.configuration.lists.taskCheckbox + let listStyleChanged = textView.configuration.lists != configuration.lists textView.configuration.lists = configuration.lists - if oldBulletStyle != configuration.lists.bullets || oldTaskCheckboxStyle != configuration.lists.taskCheckbox { + if listStyleChanged { textView.setNeedsDisplay(textView.visibleRect) } context.coordinator.configuration.lists = configuration.lists diff --git a/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift b/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift index 1c6950ce..bc883ad4 100644 --- a/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift +++ b/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift @@ -297,7 +297,7 @@ struct ListMarkerStyleTests { @Test("default styles preserve existing rendering") func defaultStyles() { #expect(BulletStyle.default.shape(forDepth: 1) == .filledDot) - #expect(TaskCheckboxStyle.default.usesSystemSymbol) + #expect(TaskCheckboxStyle.default.rendering == .systemSymbol) } @Test("bullet shapes clamp at the last ladder entry") @@ -308,11 +308,35 @@ struct ListMarkerStyleTests { #expect(BulletStyle(shapeLadder: []).shape(forDepth: 1) == .filledDot) } - @Test("styler records the top-level bullet depth") - func topLevelBulletDepth() { + /// `indentLevel(from:)` is 0-based and the ladder is 1-based. Drive a real + /// nested list so that conversion cannot silently go off by one. + @Test("styler records bullet depth 1-based, per nesting level") + func bulletDepthPerLevel() { let fontName = NSFont.systemFont(ofSize: 14).fontName - let attrs = MarkdownASTStyler.styleAttributes(text: "- a", fontName: fontName, fontSize: 14) - let marker = attrs.first { ($0.attributes[.bulletMarker] as? Bool) == true } - #expect(marker?.attributes[.bulletListLevel] as? Int == 1) + let attrs = MarkdownASTStyler.styleAttributes( + text: "- a\n\t- b\n\t\t- c", + fontName: fontName, + fontSize: 14 + ) + let levels = attrs + .filter { ($0.attributes[.bulletMarker] as? Bool) == true } + .compactMap { $0.attributes[.bulletListLevel] as? Int } + #expect(levels == [1, 2, 3]) + } + + @Test("checkbox size follows the style, else the font") + func checkboxSize() { + let font = NSFont.systemFont(ofSize: 14) + #expect( + TaskCheckboxGeometry.size(for: font, style: .default) + == TaskCheckboxGeometry.size(for: font) + ) + #expect(TaskCheckboxGeometry.size(for: font, style: TaskCheckboxStyle(size: 20)) == 20) + } + + @Test("checkbox gap comes from the style") + func checkboxGap() { + #expect(TaskCheckboxGeometry.boxX(contentX: 100, size: 15, gap: 6) == 79) + #expect(TaskCheckboxGeometry.boxX(contentX: 100, size: 15) == 83) } }