diff --git a/QuickMD/QuickMD/MarkdownRenderer.swift b/QuickMD/QuickMD/MarkdownRenderer.swift index 0a1bfe6..089245a 100644 --- a/QuickMD/QuickMD/MarkdownRenderer.swift +++ b/QuickMD/QuickMD/MarkdownRenderer.swift @@ -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. @@ -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 @@ -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: " ". + // 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) @@ -206,10 +243,32 @@ 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), @@ -217,11 +276,11 @@ struct MarkdownRenderer: Sendable { 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 { diff --git a/QuickMD/QuickMDTests/RendererTests.swift b/QuickMD/QuickMDTests/RendererTests.swift index f46624b..5a51af2 100644 --- a/QuickMD/QuickMDTests/RendererTests.swift +++ b/QuickMD/QuickMDTests/RendererTests.swift @@ -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() {