Skip to content
Merged
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
89 changes: 74 additions & 15 deletions QuickMD/QuickMD/MarkdownRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import AppKit
// Every style the renderer sets MUST stamp both scopes, or one pipeline
// silently loses formatting. Use these helpers — never set `.font` /
// `.foregroundColor` directly in renderer code.
// The one exception is NSParagraphStyle (list indents): SwiftUI `Text` has no
// equivalent, so it is AppKit-only by necessity — see "List Indentation".
extension AttributedString {

/// Set font in both SwiftUI and AppKit scopes.
Expand Down Expand Up @@ -144,22 +146,22 @@ struct MarkdownRenderer: Sendable {

// Task list (must check before unordered list)
if let taskMatch = parseTaskList(line) {
return renderTaskItem(taskMatch.content, indent: taskMatch.indent, checked: taskMatch.checked)
return renderTaskItem(taskMatch.content, level: taskMatch.level, checked: taskMatch.checked)
}

// Unordered list
if let bullet = ["- ", "* ", "+ "].first(where: { trimmed.hasPrefix($0) }) {
let indent = line.prefix(while: { $0 == " " || $0 == "\t" }).count
let level = Self.listLevel(for: line.prefix(while: { $0 == " " || $0 == "\t" }))
let content = String(trimmed.dropFirst(bullet.count))
return renderListItem(content, indent: indent, ordered: false, number: 0)
return renderListItem(content, level: level, ordered: false, number: 0)
}

// Ordered list
if let match = trimmed.range(of: #"^(\d+)\.\s"#, options: .regularExpression) {
let indent = line.prefix(while: { $0 == " " || $0 == "\t" }).count
let level = Self.listLevel(for: line.prefix(while: { $0 == " " || $0 == "\t" }))
let number = Int(trimmed.prefix(while: { $0.isNumber })) ?? 1
let content = String(trimmed[match.upperBound...])
return renderListItem(content, indent: indent, ordered: true, number: number)
return renderListItem(content, level: level, ordered: true, number: number)
}

// Empty line
Expand All @@ -183,20 +185,55 @@ struct MarkdownRenderer: Sendable {
return attr
}

private func renderListItem(_ text: String, indent: Int, ordered: Bool, number: Int) -> AttributedString {
let indentStr = String(repeating: " ", count: indent / 4 + (indent % 4 > 0 ? 1 : 0))
let prefix = indentStr + (ordered ? "\(number). " : "• ")
// MARK: - List Indentation
//
// A list item is one paragraph: "<indent spaces><marker> <item text>".
// Two things have to happen for it to read as a list:
//
// 1. Nesting — carried by the leading spaces in the marker prefix. It has
// to live in the CHARACTERS, not in the paragraph style, because the
// print/PDF pipeline renders blocks through SwiftUI `Text`, which
// ignores NSParagraphStyle entirely.
// 2. A hanging indent so a wrapped item's continuation lines line up under
// the item text instead of falling back to the marker's own margin
// (which made bullets/numbers and body text share one left edge).
// Only the paragraph style can express that, so the NSTextView pipeline
// gets it and `Text` degrades to flush-left wrapping as before.

/// Points between the text margin and a list marker, at 1.0 zoom. Separates
/// the list from surrounding paragraphs at every nesting level.
private static let listGutter: CGFloat = 16

/// Spaces prepended per nesting level.
private static let listIndentUnit = " "

/// Nesting level from a list line's leading whitespace, counting a tab as
/// one nesting step.
///
/// Authors nest with either two or four spaces and a single line can't say
/// which, so we count one level per two columns: a two-space document keeps
/// every level distinct (dividing by four would collapse its first two
/// levels onto the same margin), and a four-space document simply indents a
/// step deeper than authored. Capped so a pathological indent can't push
/// text off the right edge.
static func listLevel(for whitespace: Substring) -> Int {
let columns = whitespace.reduce(0) { $0 + ($1 == "\t" ? 2 : 1) }
return min(columns / 2, 8)
}

private func renderListItem(_ text: String, level: Int, ordered: Bool, number: Int) -> AttributedString {
let prefix = Self.indentSpaces(level) + (ordered ? "\(number). " : "• ")
var attr = AttributedString(prefix)
attr.setDualFont(size: scaled(14))
attr.setDualForeground(theme.textColor)
attr.append(renderInlineFormatting(text))
applyListParagraphStyle(&attr, hangingUnder: prefix)
return attr
}

private func renderTaskItem(_ text: String, indent: Int, checked: Bool) -> AttributedString {
let indentStr = String(repeating: " ", count: indent / 4 + (indent % 4 > 0 ? 1 : 0))
let checkbox = checked ? "☑ " : "☐ "
var attr = AttributedString(indentStr + checkbox)
private func renderTaskItem(_ text: String, level: Int, checked: Bool) -> AttributedString {
let prefix = Self.indentSpaces(level) + (checked ? "☑ " : "☐ ")
var attr = AttributedString(prefix)
attr.setDualFont(size: scaled(14))
attr.setDualForeground(checked ? theme.checkboxColor : theme.textColor)

Expand All @@ -206,22 +243,44 @@ struct MarkdownRenderer: Sendable {
content.setDualForeground(theme.secondaryTextColor)
}
attr.append(content)
applyListParagraphStyle(&attr, hangingUnder: prefix)
return attr
}

private func parseTaskList(_ line: String) -> (content: String, indent: Int, checked: Bool)? {
private static func indentSpaces(_ level: Int) -> String {
String(repeating: listIndentUnit, count: level)
}

/// Indents the whole item by the list gutter and hangs its wrapped lines
/// under the item text, i.e. past `prefix` (the indent spaces + marker).
private func applyListParagraphStyle(_ attr: inout AttributedString, hangingUnder prefix: String) {
let gutter = scaled(Self.listGutter)
let style = NSMutableParagraphStyle()
style.firstLineHeadIndent = gutter
style.headIndent = gutter + width(of: prefix)
attr[AttributeScopes.AppKitAttributes.ParagraphStyleAttribute.self] = style
}

/// Typeset width of body-font text — the marker prefix, to size the hang.
private func width(of string: String) -> CGFloat {
(string as NSString)
.size(withAttributes: [.font: NSFont.systemFont(ofSize: scaled(14))])
.width
}

private func parseTaskList(_ line: String) -> (content: String, level: Int, checked: Bool)? {
let nsRange = NSRange(line.startIndex..., in: line)

guard let match = Self.taskListRegex.firstMatch(in: line, range: nsRange),
let indentRange = Range(match.range(at: 1), in: line),
let checkRange = Range(match.range(at: 2), in: line),
let contentRange = Range(match.range(at: 3), in: line) else { return nil }

let indent = line[indentRange].count
let level = Self.listLevel(for: line[indentRange])
let checked = line[checkRange].lowercased() == "x"
let content = String(line[contentRange])

return (content: content, indent: indent, checked: checked)
return (content: content, level: level, checked: checked)
}

private func renderHorizontalRule() -> AttributedString {
Expand Down
66 changes: 66 additions & 0 deletions QuickMD/QuickMDTests/RendererTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,72 @@ final class RendererTests: XCTestCase {
XCTAssertTrue(checked.contains("☑"))
}

// MARK: - List indentation

/// Paragraph style of the first list item in `markdown`, as the NSTextView
/// pipeline sees it (SwiftUI `Text` ignores paragraph styles entirely).
private func listStyle(_ markdown: String, scale: CGFloat = 1.0) throws -> NSParagraphStyle {
let r = MarkdownRenderer(theme: MarkdownTheme.cached(for: .light), fontScale: scale)
let ns = try NSAttributedString(r.render(markdown), including: \.appKit)
let style = ns.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle
return try XCTUnwrap(style, "list item carries no paragraph style")
}

/// The bug: with no hanging indent a wrapped item's continuation lines fall
/// back to the marker's margin, so bullets/numbers and text share one edge.
func testListItemHangsWrappedLinesPastItsMarker() throws {
for markdown in ["- alpha", "1. alpha", "- [ ] alpha", "- [x] alpha"] {
let style = try listStyle(markdown)
XCTAssertGreaterThan(style.firstLineHeadIndent, 0,
"\(markdown): marker sits flush against the text margin")
XCTAssertGreaterThan(style.headIndent, style.firstLineHeadIndent,
"\(markdown): wrapped lines are not hung under the item text")
}
}

/// A wider marker has to hang further, or "10." would overlap its own text.
func testWiderOrderedMarkerHangsFurther() throws {
let single = try listStyle("1. alpha")
let double = try listStyle("10. alpha")
XCTAssertGreaterThan(double.headIndent, single.headIndent)
}

func testNestedItemsIndentDeeperThanTheirParent() {
// Two-space and four-space nesting are both common, and neither may
// collapse a level onto its parent's margin.
for unit in [" ", " "] {
let levels = (0...3).map { MarkdownRenderer.listLevel(for: Substring(String(repeating: unit, count: $0))) }
XCTAssertEqual(levels, levels.sorted(), "unit '\(unit)': levels not monotonic")
XCTAssertEqual(Set(levels).count, levels.count, "unit '\(unit)': two levels share one margin")
XCTAssertEqual(levels.first, 0, "unit '\(unit)': top level must be 0")
}
}

func testTabIndentIsOneNestingStep() {
XCTAssertEqual(MarkdownRenderer.listLevel(for: "\t"), 1)
XCTAssertEqual(MarkdownRenderer.listLevel(for: "\t\t"), 2)
}

func testListLevelIsCappedForRunawayIndents() {
XCTAssertLessThanOrEqual(MarkdownRenderer.listLevel(for: Substring(String(repeating: " ", count: 400))), 8)
}

/// Indents are point values, so they have to follow ⌘+ / ⌘- like the fonts.
func testListIndentScalesWithZoom() throws {
let normal = try listStyle("- alpha")
let zoomed = try listStyle("- alpha", scale: 2.0)
XCTAssertEqual(zoomed.firstLineHeadIndent, normal.firstLineHeadIndent * 2, accuracy: 0.01)
XCTAssertGreaterThan(zoomed.headIndent, normal.headIndent)
}

/// Nesting must stay in the characters too: the print/PDF pipeline renders
/// through SwiftUI `Text`, which drops paragraph styles.
func testNestingSurvivesInPlainTextForPrintPipeline() {
let out = String(renderer.render("- top\n - nested\n").characters)
XCTAssertTrue(out.contains("• top"))
XCTAssertTrue(out.contains(" • nested"))
}

// MARK: - Footnote references

func testFootnoteReferenceRendersSuperscript() {
Expand Down
Loading