style: align rendered bullet and numbered lists

This commit is contained in:
Valdemar Lorenzen
2026-10-19 12:47:20 +02:00
parent f275695d2f
commit 85425ee71b
3 changed files with 385 additions and 62 deletions
+185 -17
View File
@@ -293,7 +293,8 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
reset("- ab🦊cd")
editor.setSelectedRange((editor.string as NSString).range(of: "🦊"))
editor.keyDown(with: enter)
#expect(editor.string == "- ab\n- cd")
#expect(editor.string == "\u{fffc} ab\n\u{fffc} cd")
#expect(editor.markdownString == "- ab\n- cd")
#expect(editor.selectedRange() == NSRange(location: 7, length: 0))
reset("- [X] ab🦊cd")
editor.setSelectedRange((editor.string as NSString).range(of: "🦊"))
@@ -303,25 +304,25 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
#expect(editor.selectedRange() == NSRange(location: 7, length: 0))
reset("- parent\n - child\n - leaf")
editor.keyDown(with: enter)
#expect(editor.string == "- parent\n - child\n - leaf\n - ")
#expect(editor.string == "\u{fffc} parent\n \u{fffc} child\n \u{fffc} leaf\n \u{fffc} ")
editor.keyDown(with: enter)
#expect(editor.string == "- parent\n - child\n - leaf\n - ")
#expect(editor.string == "\u{fffc} parent\n \u{fffc} child\n \u{fffc} leaf\n \u{fffc} ")
editor.keyDown(with: enter)
#expect(editor.string == "- parent\n - child\n - leaf\n- ")
#expect(editor.string == "\u{fffc} parent\n \u{fffc} child\n \u{fffc} leaf\n\u{fffc} ")
editor.keyDown(with: enter)
#expect(editor.string == "- parent\n - child\n - leaf\n")
#expect(editor.string == "\u{fffc} parent\n \u{fffc} child\n \u{fffc} leaf\n")
// Nesting follows actual ancestor indentation, not a fixed depth or indent size.
let ancestors = (0..<80).map { String(repeating: " ", count: $0 * 2) + "- level \($0)" }.joined(separator: "\n")
let before = ancestors + "\n" + String(repeating: " ", count: 160) + "- leaf"
reset(before)
editor.keyDown(with: enter)
#expect(editor.string == before + "\n" + String(repeating: " ", count: 160) + "- ")
#expect(editor.markdownString == before + "\n" + String(repeating: " ", count: 160) + "- ")
for depth in stride(from: 79, through: 0, by: -1) {
editor.keyDown(with: enter)
#expect(editor.string == before + "\n" + String(repeating: " ", count: depth * 2) + "- ")
#expect(editor.markdownString == before + "\n" + String(repeating: " ", count: depth * 2) + "- ")
}
editor.keyDown(with: enter)
#expect(editor.string == before + "\n")
#expect(editor.markdownString == before + "\n")
reset("- [X] parent\n - [ ] ")
editor.keyDown(with: enter)
editor.breakUndoCoalescing()
@@ -336,11 +337,14 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
reset("7. saved")
editor.keyDown(with: enter)
editor.breakUndoCoalescing()
#expect(editor.string == "7. saved\n8. ")
#expect(editor.string == "\u{fffc} saved\n\u{fffc} ")
#expect(editor.markdownString == "7. saved\n8. ")
editor.undoManager?.undo()
#expect(editor.string == "7. saved")
#expect(editor.string == "\u{fffc} saved")
#expect(editor.markdownString == "7. saved")
editor.undoManager?.redo()
#expect(editor.string == "7. saved\n8. ")
#expect(editor.string == "\u{fffc} saved\n\u{fffc} ")
#expect(editor.markdownString == "7. saved\n8. ")
let restored = try NotesStore(directory: directory)
let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:)))
#expect(restored.notes.first { $0.id == id }?.contents.string == "7. saved\n8. ")
@@ -422,7 +426,7 @@ func taskCheckboxesToggleMarkdownWithUndoPersistenceAndListContinuation() throws
}
@Test @MainActor
func atomicTasksExpandAtDeletionBoundariesAndNormalizeLineJoinsInOneUndo() throws {
func atomicListMarkersExpandAtDeletionBoundariesAndNormalizeLineJoinsInOneUndo() throws {
_ = NSApplication.shared
let suite = "NotesTaskDeletion.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
@@ -457,16 +461,22 @@ func atomicTasksExpandAtDeletionBoundariesAndNormalizeLineJoinsInOneUndo() throw
("- [ ] buy", false, " [ ] buy"),
("\t+ [X]\t🦊", false, "\t [X]\t🦊"),
(" 12)\t[x] done", false, " 2)\t[x] done"),
("- buy", true, " buy"),
("\t+\t🦊", true, "\t\t🦊"),
(" 123)\tdone", true, " 123\tdone"),
("* buy", false, " buy"),
(" 123. done", false, " 23. done"),
]
for (source, backward, expanded) in boundaries {
let rendered = reset(source)
let token = (rendered as NSString).range(of: "\u{fffc}")
#expect(token.length == 1)
let marker = editor.markdownRange(for: token)
editor.setSelectedRange(NSRange(location: token.location + (backward ? 1 : 0), length: 0))
delete(backward: backward)
#expect(editor.string == expanded)
#expect(editor.markdownString == expanded)
let caret = backward ? (source as NSString).range(of: "]").location : token.location
let caret = backward ? NSMaxRange(marker) - 1 : token.location
#expect(editor.selectedRange() == NSRange(location: caret, length: 0))
editor.undoManager?.undo()
#expect(editor.string == rendered)
@@ -477,6 +487,8 @@ func atomicTasksExpandAtDeletionBoundariesAndNormalizeLineJoinsInOneUndo() throw
#expect(editor.markdownString == expanded)
}
let joins = [
("prose\n- item", "prose- item", "prose- item"),
("\n123) item", "123) item", "\u{fffc} item"),
("prose\n- [ ] task", "prose- [ ] task", "prose- [ ] task"),
("prose\n - [X] task", "prose - [X] task", "prose - [X] task"),
("🦊\n\t12) [x]\tfinish", "🦊\t12) [x]\tfinish", "🦊\t12) [x]\tfinish"),
@@ -523,7 +535,7 @@ func atomicTasksCopySelectAndPersistMarkdownAlongsideUnicodeAndImages() throws {
let source = NSMutableAttributedString(string: "\t12)\t[X]\t🦊 ")
let imageLocation = source.length
source.append(NSAttributedString(attachment: NSTextAttachment(fileWrapper: imageFile)))
source.append(NSAttributedString(string: "\n - [ ] tail"))
source.append(NSAttributedString(string: "\n - [ ] tail\n+ bullet\n009) numbered"))
let store = try NotesStore(directory: directory)
let id = try store.create()
try store.update(id: id, contents: source)
@@ -536,7 +548,7 @@ func atomicTasksCopySelectAndPersistMarkdownAlongsideUnicodeAndImages() throws {
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
window.makeFirstResponder(editor)
let rendered = "\t\u{fffc}\t🦊 \u{fffc}\n \u{fffc} tail"
let rendered = "\t\u{fffc}\t🦊 \u{fffc}\n \u{fffc} tail\n\u{fffc} bullet\n\u{fffc} numbered"
#expect(editor.string == rendered)
#expect(editor.markdownString == source.string)
let token = NSRange(location: 1, length: 1)
@@ -568,7 +580,7 @@ func atomicTasksCopySelectAndPersistMarkdownAlongsideUnicodeAndImages() throws {
editor.setSelectedRange(token)
editor.deleteBackward(nil)
editor.breakUndoCoalescing()
#expect(editor.string == "\t\t🦊 \u{fffc}\n \u{fffc} tail")
#expect(editor.string == "\t\t🦊 \u{fffc}\n \u{fffc} tail\n\u{fffc} bullet\n\u{fffc} numbered")
#expect(editor.markdownString == (source.string as NSString).replacingCharacters(in: marker, with: ""))
editor.undoManager?.undo()
#expect(editor.string == rendered)
@@ -615,7 +627,10 @@ func taskTokensRequireTrailingWhitespaceAndAcceptEmptyBracketsWhileTyping() thro
for character in marker {
typed.append(character)
editor.insertText(String(character), replacementRange: editor.selectedRange())
#expect(editor.string == typed)
let ordinaryPrefix = ["- ", "\t+ ", "12) "].contains(typed)
let indentation = String(typed.prefix { $0 == " " || $0 == "\t" })
#expect(editor.string == (ordinaryPrefix ? indentation + "\u{fffc} " : typed))
#expect(editor.markdownString == typed)
}
editor.insertText(" ", replacementRange: editor.selectedRange())
let indentation = String(marker.prefix { $0 == " " || $0 == "\t" })
@@ -655,4 +670,157 @@ func taskTokensRequireTrailingWhitespaceAndAcceptEmptyBracketsWhileTyping() thro
editor.insertText("- [] ", replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
editor.keyDown(with: enter)
#expect(editor.markdownString.isEmpty)
for source in ["- item", "* item", "+ item", "001) item", "09. item"] {
editor.setMarkdownContents(NSAttributedString(string: ""))
editor.setSelectedRange(NSRange(location: 0, length: 0))
var typed = ""
for character in source {
typed.append(character)
editor.insertText(String(character), replacementRange: editor.selectedRange())
#expect(editor.markdownString == typed)
}
#expect(editor.string == "\u{fffc} item")
#expect(editor.subviews.compactMap { $0 as? NSButton }.isEmpty)
}
for literal in [
"---", "- - -", "* * *", "___", "-word", "1.word", "prose - item",
"```md\n- literal\n123) literal\n```", "~~~\n+ literal\n~~~",
"- [", "- []", "- [ ]", "12) [X]", "- []not a task",
] {
editor.setMarkdownContents(NSAttributedString(string: literal))
#expect(editor.string == literal)
#expect(editor.markdownString == literal)
#expect(editor.subviews.compactMap { $0 as? NSButton }.isEmpty)
}
}
@Test @MainActor
func mixedListMarkersAlignBodyGlyphsAndWrappedLinesWithoutChangingMarkdown() throws {
_ = NSApplication.shared
let suite = "NotesListAlignment.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
defer {
defaults.removePersistentDomain(forName: suite)
try? FileManager.default.removeItem(at: directory)
}
let controller = NotesController(defaults: defaults, directory: directory)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 520, height: 400),
styleMask: .borderless, backing: .buffered, defer: false)
window.contentViewController = controller
window.setContentSize(NSSize(width: 520, height: 400))
defer { _ = controller.close(); window.orderOut(nil) }
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
let continuation = String(repeating: " wrapped words", count: 24)
let rows = [
("", "-", " ", "bullet"), ("", "*", "\t", "asterisk"),
("", "+", " ", "plus"), ("", "1.", " ", "ordered"),
("", "123456789)", " ", "long"), ("", "- [ ]", " ", "task"),
(" ", "+", " ", "nestedBullet"), (" ", "00012.", "\t", "nestedNumber"),
(" ", "* [X]", " ", "nestedTask"),
]
let source = rows.map { $0.0 + $0.1 + $0.2 + $0.3 + continuation }.joined(separator: "\n")
editor.insertText(source, replacementRange: editor.selectedRange())
window.contentView?.layoutSubtreeIfNeeded()
let layout = try #require(editor.layoutManager)
let container = try #require(editor.textContainer)
layout.ensureLayout(for: container)
let rendered = editor.string as NSString
func position(_ glyph: Int) -> NSPoint {
let fragment = layout.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil)
let location = layout.location(forGlyphAt: glyph)
return NSPoint(x: fragment.minX + location.x, y: fragment.minY + location.y)
}
var bodyColumns: [CGFloat] = []
for (index, row) in rows.enumerated() {
let body = rendered.range(of: row.3 + continuation)
#expect(body.location != NSNotFound)
let glyphs = layout.glyphRange(forCharacterRange: body, actualCharacterRange: nil)
let column = position(glyphs.location).x
bodyColumns.append(column)
let paragraph = rendered.paragraphRange(for: body)
let marker = rendered.range(of: "\u{fffc}", options: [], range: paragraph)
#expect(marker.length == 1)
let markerGlyphs = layout.glyphRange(forCharacterRange: marker, actualCharacterRange: nil)
#expect(layout.boundingRect(forGlyphRange: markerGlyphs, in: container).maxX <= column + 0.5)
var lines = 0
layout.enumerateLineFragments(forGlyphRange: glyphs) { _, _, _, lineGlyphs, _ in
if lines > 0 {
#expect(abs(position(lineGlyphs.location).x - column) < 0.5,
"Wrapped body column for row \(index)")
}
lines += 1
}
#expect(lines > 1, "Row \(index) must wrap to exercise hanging indentation")
let style = try #require(editor.textStorage?.attribute(.paragraphStyle, at: body.location, effectiveRange: nil) as? NSParagraphStyle)
#expect(style.headIndent > 0)
}
for column in bodyColumns.prefix(6) { #expect(abs(column - bodyColumns[0]) < 0.5) }
for column in bodyColumns.suffix(3) { #expect(abs(column - bodyColumns[6]) < 0.5) }
#expect(bodyColumns[6] > bodyColumns[0])
#expect(editor.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.off, .on])
#expect(editor.markdownString == source)
let sourceMarker = (source as NSString).range(of: "123456789)")
let renderedMarker = editor.renderedRange(for: sourceMarker)
#expect(rendered.substring(with: renderedMarker) == "\u{fffc}")
#expect(editor.markdownRange(for: renderedMarker) == sourceMarker)
let board = NSPasteboard.withUniqueName()
defer { board.releaseGlobally() }
editor.setSelectedRange(renderedMarker)
#expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes))
#expect(board.string(forType: .string) == "123456789)")
editor.setSelectedRange(NSRange(location: 0, length: rendered.length))
board.clearContents()
#expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes))
#expect(board.string(forType: .string) == source)
let copiedData = try #require(board.data(forType: .rtfd))
let copied = try NSAttributedString(data: copiedData,
options: [.documentType: NSAttributedString.DocumentType.rtfd], documentAttributes: nil)
#expect(copied.string == source)
#expect(controller.save())
let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:)))
let saved = try #require(NotesStore(directory: directory).notes.first { $0.id == id })
for contents in [editor.markdownContents, copied, saved.contents] {
#expect(contents.string == source)
contents.enumerateAttribute(.paragraphStyle, in: NSRange(location: 0, length: contents.length)) { value, _, _ in
let style = value as? NSParagraphStyle
#expect((style?.headIndent ?? 0) == 0)
#expect((style?.firstLineHeadIndent ?? 0) == 0)
}
}
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
editor.setSelectedRange(renderedMarker)
editor.deleteBackward(nil)
editor.breakUndoCoalescing()
#expect(editor.markdownString == (source as NSString).replacingCharacters(in: sourceMarker, with: ""))
editor.undoManager?.undo()
#expect(editor.string == rendered as String)
#expect(editor.markdownString == source)
#expect(editor.undoManager?.canUndo == false)
let markerAttribute = NSAttributedString.Key("ListMarkerSourceTest")
let originalStyle = NSMutableParagraphStyle()
originalStyle.firstLineHeadIndent = 3
originalStyle.headIndent = 7
let attributedSource = NSMutableAttributedString(string: "123) item", attributes: [.paragraphStyle: originalStyle])
attributedSource.addAttribute(markerAttribute, value: "first", range: NSRange(location: 0, length: 1))
attributedSource.addAttribute(markerAttribute, value: "third", range: NSRange(location: 2, length: 1))
editor.setMarkdownContents(attributedSource)
let roundTrip = editor.markdownContents
#expect(roundTrip.attribute(markerAttribute, at: 0, effectiveRange: nil) as? String == "first")
#expect(roundTrip.attribute(markerAttribute, at: 2, effectiveRange: nil) as? String == "third")
let restoredStyle = try #require(roundTrip.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle)
#expect(restoredStyle.firstLineHeadIndent == 3)
#expect(restoredStyle.headIndent == 7)
editor.undoManager?.removeAllActions()
editor.setSelectedRange(NSRange(location: 1, length: 0))
editor.deleteBackward(nil)
editor.breakUndoCoalescing()
#expect(editor.markdownString == "123 item")
#expect(editor.markdownContents.attribute(markerAttribute, at: 2, effectiveRange: nil) as? String == "third")
editor.undoManager?.undo()
#expect(editor.markdownString == attributedSource.string)
#expect(editor.markdownContents.attribute(markerAttribute, at: 0, effectiveRange: nil) as? String == "first")
#expect(editor.markdownContents.attribute(markerAttribute, at: 2, effectiveRange: nil) as? String == "third")
}