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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- List geometry knobs so an embedder can pin the marker column:
`ListStyle.leadingIndent` (first-line indent of every list item, previously a
silent reuse of `indentPerLevel`), `ListStyle.markerColumnWidth` (marker
column start to item text, kerned onto the `-` glyph for unordered items),
`ListStyle.markerCenterOffset` (bullet / checkbox centre inside that column,
shared by drawing and the checkbox hit-test), and `BulletStyle.diameter`.
The filled dot is now drawn as a vector like the other shapes, so one
diameter and one x-height-midline baseline apply at every depth, and the
checkbox centres on the text's cap height. Defaults reproduce the previous
rendering.

### Fixed
- A pinned marker column now measures the item's own marker (`*` and `+`, not
always `-`), so every unordered marker puts its content in the same column.
- Bullet shapes snap their origin to the device pixel grid, like the task
checkbox already did, so a small drawn dot is not blurred across two pixels.
- In `.scrolls`, the body re-fills its viewport after a viewport change even
when the content height and the resolved overscroll are both unchanged.
`recalcOverscroll` used to skip the frame update in that case, so the text
view could stay shorter than its clip: the strip below the text then belonged
to the container and a click there placed no caret.

## [0.10.0] - 2026-07-15

### Added
Expand Down
112 changes: 110 additions & 2 deletions Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -270,8 +270,94 @@ 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?
/// Ink diameter of the drawn shape, in points. nil derives it from the
/// font (`round(pointSize * 0.32)`), which is the historical size.
public var diameter: CGFloat?
/// Stroke width of the `.hollowRing` shape.
public var ringStrokeWidth: CGFloat

public init(
shapeLadder: [BulletShape] = [.filledDot],
color: NSColor? = nil,
diameter: CGFloat? = nil,
ringStrokeWidth: CGFloat = 1
) {
self.shapeLadder = shapeLadder
self.color = color
self.diameter = diameter
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 @@ -280,23 +366,45 @@ public struct ListStyle: Sendable {
public var autoClosePairsEnabled: Bool
/// Indent (in points) that one nesting level adds to the list item.
public var indentPerLevel: CGFloat
/// `firstLineHeadIndent` of every list item, i.e. the x of the marker
/// column for a depth-0 item. The default matches the historical
/// behaviour, where a list item silently reused `indentPerLevel`.
public var leadingIndent: CGFloat
/// Distance from the marker column start to the item text. nil keeps the
/// natural advance of the `"- "` marker. Unordered items only.
public var markerColumnWidth: CGFloat?
/// x of the bullet / checkbox centre, measured from the marker column
/// start. nil centres the shape inside the marker advance.
public var markerCenterOffset: CGFloat?
/// Maximum nesting level reachable by pressing Tab inside a list.
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,
leadingIndent: CGFloat = 27.5,
markerColumnWidth: CGFloat? = nil,
markerCenterOffset: CGFloat? = nil,
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.leadingIndent = leadingIndent
self.markerColumnWidth = markerColumnWidth
self.markerCenterOffset = markerCenterOffset
self.maximumNestingLevel = maximumNestingLevel
self.extraLineHeight = extraLineHeight
self.bullets = bullets
self.taskCheckbox = taskCheckbox
}

public static let `default` = ListStyle()
Expand Down
127 changes: 97 additions & 30 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 @@ -508,10 +511,12 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {

// MARK: - Bullet Markers

/// Paint a `•` over every hidden bullet marker (`.bulletMarker`). The
/// glyph is drawn in the same font as the source so its baseline matches
/// the surrounding text, and centered within the original marker char's
/// advance so a `•` of a different width still sits where `-`/`*`/`+` was.
/// Paint a vector shape over every hidden bullet marker (`.bulletMarker`).
/// Every depth uses the same drawn shape, so one diameter
/// (`BulletStyle.diameter`, else `round(pointSize * 0.32)`) governs all of
/// them. The centre sits on the x-height midline, and at
/// `ListStyle.markerCenterOffset` from the marker column start when the
/// embedder pins the column; otherwise in the middle of the marker advance.
private func drawBulletMarkers(at point: CGPoint, in context: CGContext) {
guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return }
let selectionRanges: [NSRange] = {
Expand All @@ -524,8 +529,10 @@ 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 lists = configuration?.lists ?? .default
let style = lists.bullets
let storageString = ts.string as NSString

ts.enumerateAttribute(.bulletMarker, in: range, options: []) { [weak self] value, attrRange, _ in
Expand All @@ -536,16 +543,46 @@ 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)
let diameter = style.diameter ?? round(font.pointSize * 0.32)
// Flipped context: y grows downwards, so the x-height midline sits
// half an x-height above the baseline.
let center = CGPoint(
x: pos.x + (lists.markerCenterOffset ?? markerWidth / 2),
y: pos.baselineY - font.xHeight / 2
)
// Snap the origin to the device grid, like the checkbox: an
// unaligned 4.5pt shape lands on half pixels and renders as a
// blurred, off-centre blob that also moves between capture runs.
let scale = self.textLayoutManager?.textContainer?.textView?.window?.backingScaleFactor
?? NSScreen.main?.backingScaleFactor ?? 2.0
func alignToPixel(_ value: CGFloat) -> CGFloat {
(value * scale).rounded(.toNearestOrAwayFromZero) / scale
}
let rect = CGRect(x: alignToPixel(center.x - diameter / 2),
y: alignToPixel(center.y - diameter / 2),
width: diameter, height: diameter)
color.set()
switch shape {
case .filledDot:
NSBezierPath(ovalIn: rect).fill()
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: rect.midX, 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 +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 }
Expand All @@ -571,16 +610,21 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {
guard let pos = drawPosition(forDocumentCharAt: attrRange.location, point: point) else { return }

// Box collapsed to 0.1pt, so pos.x sits at the content edge; the
// square is right-aligned to it (shared with the click hit-test).
// square is placed from it by the shared geometry — centred on the
// marker column when the embedder pins one, else right-aligned
// (shared with the click hit-test).
// Use baseFont, NOT NSTextView.font — its getter returns the first
// 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 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 centerY = pos.baselineY + (descent - ascent) / 2
let size = TaskCheckboxGeometry.size(for: font, style: style)
let lists = textView?.configuration.lists ?? .default
let boxX = TaskCheckboxGeometry.boxX(contentX: pos.x, size: size, lists: lists)
// Centred on the cap-height midline, which is where the eye reads
// the box as level with the text (flipped context).
let centerY = pos.baselineY - font.capHeight / 2
let boxY = centerY - size / 2

let scale = textLayoutManager?.textContainer?.textView?.window?.backingScaleFactor
Expand All @@ -591,17 +635,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
Loading