diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 0ce6261b..c54b0482 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -270,8 +270,89 @@ public struct InlineCodeStyle: Sendable { // MARK: - Lists +public enum BulletShape: Sendable, Equatable { + case filledDot + case hollowRing + case smallSquare + case triangle +} + +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? + /// Stroke width of the `.hollowRing` shape. + public var ringStrokeWidth: CGFloat + + public init( + shapeLadder: [BulletShape] = [.filledDot], + color: NSColor? = nil, + ringStrokeWidth: CGFloat = 1 + ) { + self.shapeLadder = shapeLadder + self.color = color + self.ringStrokeWidth = ringStrokeWidth + } + + public static let `default` = BulletStyle() + + /// 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 { + /// 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 + 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`. + public var checkedFillColor: NSColor? + /// nil uses `NSColor.white`; the theme has no background color. + public var checkmarkColor: NSColor? + + public init( + rendering: Rendering = .systemSymbol, + size: CGFloat? = nil, + strokeWidth: CGFloat = 1, + cornerRadius: CGFloat = 3, + gap: CGFloat = 2, + uncheckedColor: NSColor? = nil, + checkedFillColor: NSColor? = nil, + checkmarkColor: NSColor? = nil + ) { + self.rendering = rendering + self.size = size + self.strokeWidth = strokeWidth + self.cornerRadius = cornerRadius + self.gap = gap + self.uncheckedColor = uncheckedColor + self.checkedFillColor = checkedFillColor + self.checkmarkColor = checkmarkColor + } + + public static let `default` = TaskCheckboxStyle() +} + /// 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. @@ -284,19 +365,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..e1ad977e 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -24,6 +24,9 @@ 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") /// Int — hash of source text; key for overlay reconcile + offset persistence. @@ -524,8 +527,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 +540,47 @@ 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 color = style.color ?? theme.bodyText + let shape = style.shape(forDepth: level) + 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) + 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 // Unreachable: the glyph path above already returned. + case .hollowRing: + let path = NSBezierPath(ovalIn: rect) + path.lineWidth = style.ringStrokeWidth + 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() + } } } @@ -562,6 +597,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,8 +615,8 @@ 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 boxX = TaskCheckboxGeometry.boxX(contentX: pos.x, size: size) + let size = TaskCheckboxGeometry.size(for: font, style: style) + let boxX = TaskCheckboxGeometry.boxX(contentX: pos.x, size: size, gap: style.gap) let centerY = pos.baselineY + (descent - ascent) / 2 let boxY = centerY - size / 2 @@ -591,17 +628,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.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" + 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..9c7326d9 100644 --- a/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift +++ b/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift @@ -28,8 +28,12 @@ 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 { + static func boxX(contentX: CGFloat, size: CGFloat, gap: CGFloat = gap) -> 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..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) + 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) diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index cfae673e..c51e868d 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -469,7 +469,11 @@ 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 listStyleChanged = textView.configuration.lists != configuration.lists textView.configuration.lists = configuration.lists + if listStyleChanged { + 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..bc883ad4 100644 --- a/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift +++ b/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift @@ -289,3 +289,54 @@ 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 { + + /// The default style must still hit the `•` glyph and SF Symbol paths. + @Test("default styles preserve existing rendering") + func defaultStyles() { + #expect(BulletStyle.default.shape(forDepth: 1) == .filledDot) + #expect(TaskCheckboxStyle.default.rendering == .systemSymbol) + } + + @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) + } + + /// `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\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) + } +}