style: align rendered bullet and numbered lists
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user