Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
Expand Down
108 changes: 84 additions & 24 deletions Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
}
}

Expand All @@ -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 }
Expand All @@ -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

Expand All @@ -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()
}
}
}
}
Expand Down
6 changes: 5 additions & 1 deletion Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
6 changes: 4 additions & 2 deletions Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}