From 60a450777975d9dcfcece01b0715a8553bd3325c Mon Sep 17 00:00:00 2001 From: Assaf Inbal Date: Mon, 27 Jul 2026 13:29:13 +0300 Subject: [PATCH] fix(lists): indent list items and hang their wrapped lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit List items rendered as a plain paragraph whose only indentation was leading spaces. NSTextView's default paragraph style has zero head indents, so a wrapped item's continuation lines fell back to the marker's own margin — bullets, numbers, checkboxes and body text all shared one left edge. Items now carry an AppKit paragraph style: a 16pt gutter in front of the marker, and a headIndent of gutter + the typeset width of the item's prefix so continuation lines hang under the item text. The width is measured from the actual marker, so "10." hangs further than "1.", and both follow ⌘+ / ⌘-. Bullets, ordered items and task checkboxes all go through it. Also fixes the nesting level, which was ceil(columns / 4) and put a 2-space-nested child on its parent's margin. Now one level per two columns (one per tab), capped at 8, so 2- and 4-space documents both keep every level distinct. Nesting stays in the characters as well as the paragraph style: the print/PDF pipeline renders text blocks through SwiftUI Text, which ignores NSParagraphStyle, so it keeps today's space-based nesting and simply degrades to flush-left wrapping. Co-Authored-By: Claude Opus 5 --- QuickMD/QuickMD/MarkdownRenderer.swift | 89 ++++++++++++++++++++---- QuickMD/QuickMDTests/RendererTests.swift | 66 ++++++++++++++++++ 2 files changed, 140 insertions(+), 15 deletions(-) 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() {