Files
conjure/Tests/ConjureKitTests/NotesEditingTests.swift
T
2026-10-19 15:11:47 +02:00

1285 lines
69 KiB
Swift

import AppKit
import ConjureKit
import Testing
@testable import Conjure
@Test @MainActor
func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws {
_ = NSApplication.shared
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let suite = "NotesEditing.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer {
try? FileManager.default.removeItem(at: directory)
defaults.removePersistentDomain(forName: suite)
}
let controller = NotesController(defaults: defaults, directory: directory)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 390), styleMask: [.borderless], backing: .buffered, defer: false)
window.contentViewController = controller
defer { _ = controller.close(); window.orderOut(nil) }
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let views = descendants(controller.view)
let editor = try #require(views.compactMap { $0 as? NSTextView }.first)
let browse = try #require(views.compactMap { $0 as? NSButton }.first { $0.title == "Browse Notes" })
#expect(browse.keyEquivalent == "p")
#expect(!views.compactMap { $0 as? NSButton }.contains { $0.title == "New Note" })
func selectedID() throws -> UUID { try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:))) }
func picker(_ tool: NotesController) throws -> NSSearchField {
try #require(descendants(tool.view).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == "Search notes" })
}
func filter(_ field: NSSearchField, _ query: String) {
field.stringValue = query
field.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: field))
field.window?.contentView?.layoutSubtreeIfNeeded()
}
let firstID = try selectedID()
// Empty notes have duplicate titles; UUID identity must survive the shared picker.
window.makeFirstResponder(editor)
let newNoteKey = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero,
modifierFlags: .command, timestamp: 1, windowNumber: window.windowNumber, context: nil,
characters: "n", charactersIgnoringModifiers: "n", isARepeat: false, keyCode: 45))
#expect(window.performKeyEquivalent(with: newNoteKey))
let secondID = try selectedID()
#expect(secondID != firstID)
for expectedID in [firstID, secondID] {
browse.performClick(nil)
_ = try picker(controller)
let table = try #require(descendants(controller.view).compactMap { $0 as? NSTableView }.first)
#expect(table.numberOfRows == 2)
#expect(controller.handleControlKey(table.selectedRow == 0 ? "j" : "k"))
#expect(controller.handle(#selector(NSResponder.insertNewline(_:))))
#expect(try selectedID() == expectedID)
#expect(window.firstResponder === editor)
}
#expect(!controller.handleControlKey("j"))
#expect(!controller.handle(#selector(NSResponder.insertNewline(_:))))
editor.insertText("# Second 🦊\n**bold** and `code`", replacementRange: editor.selectedRange())
#expect((editor.textStorage?.attribute(.font, at: 0, effectiveRange: nil) as? NSFont)?.pointSize == 17)
let codeRange = (editor.string as NSString).range(of: "`code`")
let codeFont = try #require(editor.textStorage?.attribute(.font, at: codeRange.location, effectiveRange: nil) as? NSFont)
#expect(codeFont.isFixedPitch)
browse.performClick(nil)
filter(try picker(controller), "nttld")
#expect(controller.handle(#selector(NSResponder.insertNewline(_:))))
#expect(try selectedID() == firstID)
#expect(editor.string.isEmpty)
#expect(editor.undoManager?.canUndo != true)
editor.insertText("First note", replacementRange: editor.selectedRange())
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
bitmap.setColor(.red, atX: 0, y: 0)
let imageURL = directory.appendingPathComponent("image.png")
let png = try #require(bitmap.representation(using: .png, properties: [:]))
try png.write(to: imageURL)
let pasteboard = NSPasteboard.withUniqueName()
defer { pasteboard.releaseGlobally() }
pasteboard.setData(png, forType: .png)
#expect(editor.readSelection(from: pasteboard))
pasteboard.clearContents()
pasteboard.writeObjects([imageURL as NSURL])
#expect(editor.readSelection(from: pasteboard))
#expect(controller.save())
let returnKey = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero,
modifierFlags: [], timestamp: 2, windowNumber: window.windowNumber, context: nil,
characters: "\r", charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36))
editor.keyDown(with: returnKey)
#expect(editor.string == "First note\u{fffc}\u{fffc}\n")
#expect(try selectedID() == firstID)
let restored = try NotesStore(directory: directory)
let first = try #require(restored.notes.first { $0.id == firstID })
#expect(first.contents.string == "First note\u{fffc}\u{fffc}\n")
#expect(first.contents.attribute(.attachment, at: 10, effectiveRange: nil) is NSTextAttachment)
#expect(first.contents.attribute(.attachment, at: 11, effectiveRange: nil) is NSTextAttachment)
#expect(restored.notes.first { $0.id == secondID }?.contents.string == "# Second 🦊\n**bold** and `code`")
let reopened = NotesController(defaults: defaults, directory: directory)
let reopenedWindow = NSWindow(contentRect: window.frame, styleMask: [.borderless], backing: .buffered, defer: false)
reopenedWindow.contentViewController = reopened
defer { _ = reopened.close(); reopenedWindow.orderOut(nil) }
let reopenedViews = descendants(reopened.view)
let reopenedEditor = try #require(reopenedViews.compactMap { $0 as? NotesTextView }.first)
#expect(reopenedEditor.string == first.contents.string)
// Removing Markdown must also remove its previous visual style without changing the source.
reopenedEditor.insertText("Plain text", replacementRange: NSRange(location: 0, length: reopenedEditor.string.utf16.count))
#expect((reopenedEditor.textStorage?.attribute(.font, at: 0, effectiveRange: nil) as? NSFont)?.pointSize == 14)
#expect(reopenedEditor.string == "Plain text")
let backup = directory.appendingPathExtension("backup")
defer { try? FileManager.default.removeItem(at: backup) }
try FileManager.default.moveItem(at: directory, to: backup)
try Data("Blocked storage".utf8).write(to: directory)
reopenedEditor.insertText("- [X] Unsaved", replacementRange: NSRange(location: 0, length: reopenedEditor.string.utf16.count))
var closed = false
reopened.closed = { closed = true }
reopened.goBack()
#expect(!closed)
#expect(!reopened.save())
reopened.browseNotes()
filter(try picker(reopened), "scnd")
#expect(reopened.handle(#selector(NSResponder.insertNewline(_:))))
#expect(reopenedEditor.string == "\u{fffc} Unsaved")
#expect(reopenedEditor.markdownString == "- [X] Unsaved")
#expect(try selectedID() == firstID)
reopened.browseNotes()
#expect(!reopened.close())
#expect(!closed)
#expect(try picker(reopened).window === reopenedWindow)
try FileManager.default.removeItem(at: directory)
try FileManager.default.moveItem(at: backup, to: directory)
#expect(reopened.save())
#expect(try NotesStore(directory: directory).notes.first { $0.id == firstID }?.contents.string == "- [X] Unsaved")
#expect(reopened.close())
#expect(closed)
#expect(!descendants(reopened.view).contains { ($0 as? NSSearchField)?.accessibilityLabel() == "Search notes" })
}
@Test @MainActor
func notesLauncherRoutesShortcutsAndTearsDownPickersWithoutTakingEditorNavigation() throws {
_ = NSApplication.shared
let suite = "NotesShortcuts.\(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 store = try NotesStore(directory: directory)
let atlas = try store.create()
let journal = try store.create()
try store.update(id: atlas, contents: NSAttributedString(string: "Project Atlas"))
try store.update(id: journal, contents: NSAttributedString(string: "Weekend journal"))
defaults.set(atlas.uuidString, forKey: "selectedNote")
let launcher = LauncherWindowController(
history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }),
defaults: defaults, notesDirectory: directory)
let window = try #require(launcher.window)
defer { launcher.dismiss(restoringFocus: false) }
let content = try #require(window.contentView)
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
func search(_ label: String) throws -> NSSearchField {
try #require(descendants(content).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == label })
}
func key(_ text: String, code: UInt16, flags: NSEvent.ModifierFlags = []) throws -> NSEvent {
let characters = flags == .control && text == "k" ? "\u{b}" : text
return try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags,
timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: characters,
charactersIgnoringModifiers: text, isARepeat: false, keyCode: code))
}
func command(_ text: String, code: UInt16) throws {
#expect(window.performKeyEquivalent(with: try key(text, code: code, flags: .command)))
}
func filter(_ field: NSSearchField, _ text: String) {
field.stringValue = text
field.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: field))
content.layoutSubtreeIfNeeded()
}
let launcherSearch = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
launcherSearch.stringValue = "notes"
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: launcherSearch))
#expect(launcher.control(launcherSearch, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:))))
window.makeKeyAndOrderFront(nil)
let browse = try #require(descendants(content).compactMap { $0 as? NSButton }.first { $0.title == "Browse Notes" })
let tool = try #require(browse.target as? NotesController)
let editor = try #require(descendants(content).compactMap { $0 as? NSTextView }.first { $0.accessibilityLabel() == "Note editor" })
window.makeFirstResponder(editor)
try command("p", code: 35)
let picker = try search("Search notes")
let table = try #require(descendants(content).compactMap { $0 as? NSTableView }.first { $0.accessibilityLabel() == "Notes" })
#expect(window.firstResponder === picker.currentEditor())
#expect(browse.superview?.isAccessibilityHidden() == true)
#expect(tool.handle(#selector(NSResponder.moveUp(_:))))
window.sendEvent(try key("j", code: 38, flags: .control))
#expect(table.selectedRow == 1)
window.sendEvent(try key("k", code: 40, flags: .control))
#expect(table.selectedRow == 0)
filter(picker, "wkndjrnl")
#expect(table.numberOfRows == 1)
#expect(tool.handle(#selector(NSResponder.insertNewline(_:))))
#expect(defaults.string(forKey: "selectedNote") == journal.uuidString)
#expect(editor.string == "Weekend journal")
#expect(window.firstResponder === editor)
try command("p", code: 35)
_ = try search("Search notes")
try command("p", code: 35)
#expect(window.firstResponder === editor)
try command("k", code: 40)
filter(try search("Search notes actions"), "Browse Notes")
#expect(tool.handle(#selector(NSResponder.insertNewline(_:))))
_ = try search("Search notes")
window.sendEvent(try key("\u{1b}", code: 53))
#expect(window.isVisible && window.firstResponder === editor)
try command("k", code: 40)
filter(try search("Search notes actions"), "New Note")
#expect(tool.handle(#selector(NSResponder.insertNewline(_:))))
#expect(editor.string.isEmpty)
let actionCreatedID = defaults.string(forKey: "selectedNote")
try command("n", code: 45)
#expect(defaults.string(forKey: "selectedNote") != actionCreatedID)
let editorCreatedID = defaults.string(forKey: "selectedNote")
try command("p", code: 35)
_ = try search("Search notes")
try command("n", code: 45)
#expect(defaults.string(forKey: "selectedNote") != editorCreatedID)
#expect(window.firstResponder === editor)
#expect(!tool.handle(#selector(NSResponder.moveDown(_:))))
#expect(!tool.handleControlKey("k"))
editor.insertText("Navigation", replacementRange: editor.selectedRange())
editor.setSelectedRange(NSRange(location: 0, length: 0))
window.sendEvent(try key("k", code: 40, flags: .control))
#expect(editor.string.isEmpty)
editor.insertText("Editable", replacementRange: editor.selectedRange())
editor.keyDown(with: try key("\r", code: 36))
#expect(editor.string == "Editable\n")
try command("p", code: 35)
#expect(tool.close())
#expect(!descendants(content).compactMap { $0 as? NSSearchField }.contains { $0.accessibilityLabel() == "Search notes" })
#expect(launcherSearch.isHidden == false)
}
@Test @MainActor
func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
_ = NSApplication.shared
let suite = "NotesLists.\(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: 750, height: 390),
styleMask: .borderless, backing: .buffered, defer: false)
window.contentViewController = controller
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)
window.makeFirstResponder(editor)
let enter = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "\r",
charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36))
func reset(_ text: String) {
editor.insertText(text, replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
}
let examples = [
("- fox 🦊", "- fox 🦊\n- "),
(" * nested", " * nested\n * "),
("\t+ nested", "\t+ nested\n\t+ "),
("9. ninth", "9. ninth\n10. "),
(" 12) nested", " 12) nested\n 13) "),
("- [x] finished", "- [x] finished\n- [ ] "),
(" \t+ [X]\tfinished", " \t+ [X]\tfinished\n \t+ [ ]\t"),
("1. parent\n - [X] ", "1. parent\n2. "),
("- [x] parent\n - [ ] ", "- [x] parent\n- [ ] "),
("- ", ""), ("+ \t ", ""), ("4. ", ""), ("- [ ] ", ""), ("- [X]", ""),
("- parent\n - ", "- parent\n- "),
("1. parent\n * ", "1. parent\n2. "),
("- parent\n\t+ child\n\t\t3) ", "- parent\n\t+ child\n\t+ "),
("- first\n - child\n - ", "- first\n - child\n- "),
("- parent\n continuation\n - ", "- parent\n continuation\n- "),
("- parent\nparagraph\n - ", "- parent\nparagraph\n- "),
("* * *\n - ", "* * *\n- "),
("- - -\n 4) ", "- - -\n 4) "),
("- parent\n + ", "- parent\n- "),
("- parent\r\n - ", "- parent\r\n- "),
("prose", "prose\n"), ("* * *", "* * *\n"),
("```md\n- literal", "```md\n- literal\n"),
("~~~\n 1. literal", "~~~\n 1. literal\n"),
("```\n- literal\n```\n- real", "```\n- literal\n```\n- real\n- "),
]
for (before, after) in examples {
reset(before)
editor.keyDown(with: enter)
#expect(editor.markdownString == after, "Return in \(before.debugDescription)")
#expect(editor.markdownContents.string == after)
#expect(editor.selectedRange() == NSRange(location: editor.string.utf16.count, length: 0))
}
// Empty means whitespace-only body, even before or within the extra separators.
for marker in ["-", "12)", "- [X]", "3. []"] {
let source = "- parent\n " + marker + " \t "
let bodyStart = ("- parent\n " + marker + " ").utf16.count
for position in bodyStart...source.utf16.count {
editor.setMarkdownContents(NSAttributedString(string: source))
editor.setSelectedRange(editor.renderedRange(for: NSRange(location: position, length: 0)))
editor.keyDown(with: enter)
#expect(editor.markdownString == "- parent\n- ",
"Empty Return at \(position) in \(source.debugDescription)")
}
}
for (source, steps) in [
(" + \t ", [" + \t ", " + \t ", "+ \t ", ""]),
("\t\t12) ", ["\t12) ", "12) ", ""]),
(" - [X] ", [" - [X] ", "- [X] ", ""]),
] {
editor.setMarkdownContents(NSAttributedString(string: source))
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
for expected in steps {
editor.keyDown(with: enter)
#expect(editor.markdownString == expected)
#expect(editor.selectedRange() == NSRange(location: editor.string.utf16.count, length: 0))
}
}
reset("- ab🦊cd")
editor.setSelectedRange((editor.string as NSString).range(of: "🦊"))
editor.keyDown(with: enter)
#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: "🦊"))
editor.keyDown(with: enter)
#expect(editor.markdownString == "- [X] ab\n- [ ] cd")
#expect(editor.string == "\u{fffc} ab\n\u{fffc} cd")
#expect(editor.selectedRange() == NSRange(location: 7, length: 0))
reset("- parent\n - child\n - leaf")
editor.keyDown(with: enter)
#expect(editor.string == "\u{fffc} parent\n \u{fffc} child\n \u{fffc} leaf\n \u{fffc} ")
editor.keyDown(with: enter)
#expect(editor.string == "\u{fffc} parent\n \u{fffc} child\n \u{fffc} leaf\n \u{fffc} ")
editor.keyDown(with: enter)
#expect(editor.string == "\u{fffc} parent\n \u{fffc} child\n \u{fffc} leaf\n\u{fffc} ")
editor.keyDown(with: enter)
#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.markdownString == before + "\n" + String(repeating: " ", count: 160) + "- ")
for depth in stride(from: 79, through: 0, by: -1) {
editor.keyDown(with: enter)
#expect(editor.markdownString == before + "\n" + String(repeating: " ", count: depth * 2) + "- ")
}
editor.keyDown(with: enter)
#expect(editor.markdownString == before + "\n")
reset("- [X] parent\n - [ ] ")
editor.keyDown(with: enter)
editor.breakUndoCoalescing()
#expect(editor.markdownString == "- [X] parent\n- [ ] ")
#expect(editor.string == "\u{fffc} parent\n\u{fffc} ")
editor.undoManager?.undo()
#expect(editor.markdownString == "- [X] parent\n - [ ] ")
#expect(editor.string == "\u{fffc} parent\n \u{fffc} ")
#expect(editor.undoManager?.canUndo == false)
editor.undoManager?.redo()
#expect(editor.markdownString == "- [X] parent\n- [ ] ")
reset("7. saved")
editor.keyDown(with: enter)
editor.breakUndoCoalescing()
#expect(editor.string == "\u{fffc} saved\n\u{fffc} ")
#expect(editor.markdownString == "7. saved\n8. ")
editor.undoManager?.undo()
#expect(editor.string == "\u{fffc} saved")
#expect(editor.markdownString == "7. saved")
editor.undoManager?.redo()
#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. ")
}
@Test @MainActor
func listTabAndShiftTabPreserveMarkersSourceSelectionsUndoAndPersistence() throws {
_ = NSApplication.shared
let suite = "NotesIndent.\(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: 750, height: 390),
styleMask: .borderless, backing: .buffered, defer: false)
window.contentViewController = controller
window.setContentSize(NSSize(width: 750, height: 390))
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)
window.makeFirstResponder(editor)
func key(_ shift: Bool) throws -> NSEvent {
try #require(NSEvent.keyEvent(with: .keyDown, location: .zero,
modifierFlags: shift ? .shift : [], timestamp: 1, windowNumber: window.windowNumber,
context: nil, characters: shift ? "\u{19}" : "\t", charactersIgnoringModifiers: "\t",
isARepeat: false, keyCode: 48))
}
let tab = try key(false), backtab = try key(true)
func reset(_ source: String) {
editor.setMarkdownContents(NSAttributedString(string: source))
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
}
for marker in ["-", "*", "+", "009)", "12.", "- [X]", "2) []"] {
let source = " " + marker + " \t🦊 café\n - child"
reset(source)
let body = (editor.string as NSString).range(of: "🦊 café")
// Visible indentation, marker, body start/middle/end, and a Unicode selection.
let selections = [NSRange(location: 0, length: 0), NSRange(location: 1, length: 0),
NSRange(location: 3, length: 0), NSRange(location: body.location, length: 0),
NSRange(location: body.location + 2, length: 0),
NSRange(location: NSMaxRange(body), length: 0), body]
for selection in selections {
reset(source)
editor.setSelectedRange(selection)
let logical = editor.markdownInsertionRange(for: selection)
editor.keyDown(with: tab)
editor.breakUndoCoalescing()
#expect(editor.markdownString == " " + source)
#expect(editor.markdownContents.string == " " + source)
#expect(editor.selectedRange() == editor.renderedRange(for:
NSRange(location: logical.location + 2, length: logical.length)))
editor.undoManager?.undo()
#expect(editor.markdownString == source)
#expect(editor.selectedRange() == selection)
#expect(editor.undoManager?.canUndo == false)
editor.undoManager?.redo()
#expect(editor.markdownString == " " + source)
editor.keyDown(with: backtab)
#expect(editor.markdownString == source)
#expect(editor.selectedRange() == editor.renderedRange(for: logical))
}
}
for (indent, remaining) in [("\t ", " "), (" ", " "), (" ", ""), ("", "")] {
for marker in ["+", "12)", "- [ ]"] {
let source = indent + marker + " 🦊 café"
reset(source)
let body = (editor.string as NSString).range(of: "🦊 café")
editor.setSelectedRange(body)
editor.keyDown(with: backtab)
#expect(editor.markdownString == remaining + marker + " 🦊 café")
#expect((editor.string as NSString).substring(with: editor.selectedRange()) == "🦊 café")
if indent.isEmpty { #expect(editor.undoManager?.canUndo == false) }
}
}
for source in ["prose", "* * *", "```md\n- literal", "~~~\n 12) literal"] {
reset(source)
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
#expect(!controller.textView(editor, doCommandBy: #selector(NSResponder.insertTab(_:))))
#expect(!controller.textView(editor, doCommandBy: #selector(NSResponder.insertBacktab(_:))))
#expect(editor.markdownString == source)
}
reset("- root")
editor.setSelectedRange(NSRange(location: 0, length: 0))
for _ in 0..<3 { editor.keyDown(with: backtab) }
#expect(editor.markdownString == "- root")
#expect(editor.undoManager?.canUndo == false)
// Tab completes a still-literal typed marker rather than indenting it.
for marker in ["-", "12)", "- [X]"] {
reset(" ")
editor.setSelectedRange(NSRange(location: 0, length: 0))
for character in marker {
editor.insertText(String(character), replacementRange: editor.selectedRange())
}
#expect(editor.string == marker + " ")
editor.keyDown(with: tab)
#expect(editor.markdownString == marker + "\t ")
#expect(editor.string == "\u{fffc}\t ")
editor.keyDown(with: tab)
#expect(editor.markdownString == " " + marker + "\t ")
}
reset("- 🦊 café")
editor.setSelectedRange((editor.string as NSString).range(of: "café"))
editor.keyDown(with: tab)
editor.breakUndoCoalescing()
#expect(editor.markdownString == " - 🦊 café")
#expect((editor.string as NSString).substring(with: editor.selectedRange()) == "café")
#expect(controller.save())
let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:)))
#expect(try NotesStore(directory: directory).notes.first { $0.id == id }?.contents.string == " - 🦊 café")
editor.undoManager?.undo()
#expect(editor.markdownString == "- 🦊 café")
#expect(editor.undoManager?.canUndo == false)
editor.undoManager?.redo()
#expect(editor.markdownString == " - 🦊 café")
}
@Test @MainActor
func taskCheckboxesToggleMarkdownWithUndoPersistenceAndListContinuation() throws {
_ = NSApplication.shared
let suite = "NotesTasks.\(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: 750, height: 390),
styleMask: .borderless, backing: .buffered, defer: false)
window.contentViewController = controller
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)
window.makeFirstResponder(editor)
let before = "- [ ] buy 🦊\n \t+ [X]\tnested\n```md\n- [ ] literal\n```\nprose - [ ] not a task"
let rendered = "\u{fffc} buy 🦊\n \t\u{fffc}\tnested\n```md\n- [ ] literal\n```\nprose - [ ] not a task"
editor.insertText(before, replacementRange: editor.selectedRange())
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
#expect(editor.string == rendered)
#expect(editor.markdownContents.string == before)
#expect(editor.textStorage?.attribute(.attachment, at: 0, effectiveRange: nil) is NSTextAttachment)
let boxes = editor.subviews.compactMap { $0 as? NSButton }
#expect(boxes.map(\.state) == [.off, .on])
#expect(boxes.first?.cell?.accessibilityRole() == .checkBox)
#expect(boxes.first?.accessibilityLabel() == "buy 🦊")
let selection = editor.selectedRange()
let first = try #require(boxes.first)
first.performClick(nil)
let checked = before.replacingOccurrences(of: "- [ ] buy", with: "- [x] buy")
#expect(editor.string == rendered)
#expect(editor.markdownString == checked)
#expect(first.state == .on)
#expect(editor.selectedRange() == selection)
let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:)))
let saved = try #require(NotesStore(directory: directory).notes.first { $0.id == id })
#expect(saved.contents.string == checked)
#expect(saved.contents.attribute(.attachment, at: 0, effectiveRange: nil) == nil)
editor.undoManager?.undo()
#expect(editor.markdownString == before)
#expect(editor.string == rendered)
#expect(editor.subviews.compactMap { $0 as? NSButton }.first?.state == .off)
editor.undoManager?.redo()
#expect(editor.markdownString == checked)
#expect(editor.string == rendered)
#expect(editor.subviews.compactMap { $0 as? NSButton }.first?.state == .on)
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
let board = NSPasteboard.withUniqueName()
defer { board.releaseGlobally() }
#expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes))
#expect(board.string(forType: .string) == checked)
let reopened = NotesController(defaults: defaults, directory: directory)
defer { _ = reopened.close() }
let restoredEditor = try #require(descendants(reopened.view).compactMap { $0 as? NotesTextView }.first)
#expect(restoredEditor.markdownString == checked)
#expect(restoredEditor.string == rendered)
#expect(restoredEditor.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.on, .on])
editor.insertText("- [x] finished", replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
let enter = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "\r",
charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36))
editor.keyDown(with: enter)
#expect(editor.markdownString == "- [x] finished\n- [ ] ")
#expect(editor.string == "\u{fffc} finished\n\u{fffc} ")
#expect(editor.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.on, .off])
editor.keyDown(with: enter)
#expect(editor.markdownString == "- [x] finished\n")
#expect(editor.string == "\u{fffc} finished\n")
#expect(editor.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.on])
}
@Test @MainActor
func atomicListMarkersExpandAtDeletionBoundariesAndNormalizeLineJoinsInOneUndo() throws {
_ = NSApplication.shared
let suite = "NotesTaskDeletion.\(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: 750, height: 390),
styleMask: .borderless, backing: .buffered, defer: false)
window.contentViewController = controller
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)
window.makeFirstResponder(editor)
func reset(_ source: String) -> String {
editor.insertText(source, replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
return editor.string
}
func delete(backward: Bool) {
if backward { editor.deleteBackward(nil) }
else { editor.deleteForward(nil) }
editor.breakUndoCoalescing()
}
let boundaries = [
("- [ ] buy", true, "- [ buy"),
("\t+ [X]\t🦊", true, "\t+ [X\t🦊"),
(" 12)\t[x] done", true, " 12)\t[x done"),
("- [ ] 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 ? NSMaxRange(marker) - 1 : token.location
#expect(editor.selectedRange() == NSRange(location: caret, length: 0))
editor.undoManager?.undo()
#expect(editor.string == rendered)
#expect(editor.markdownString == source)
#expect(editor.undoManager?.canUndo == false)
editor.undoManager?.redo()
#expect(editor.string == expanded)
#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"),
("\n- [ ] task", "- [ ] task", "\u{fffc} task"),
(" \t\n + [X] task", " \t + [X] task", " \t \u{fffc} task"),
]
for (source, joined, renderedJoin) in joins {
for backward in [true, false] {
let rendered = reset(source)
let newline = (rendered as NSString).range(of: "\n")
editor.setSelectedRange(NSRange(location: newline.location + (backward ? 1 : 0), length: 0))
delete(backward: backward)
#expect(editor.markdownString == joined)
#expect(editor.string == renderedJoin)
editor.undoManager?.undo()
#expect(editor.markdownString == source)
#expect(editor.string == rendered)
#expect(editor.undoManager?.canUndo == false)
editor.undoManager?.redo()
#expect(editor.string == renderedJoin)
#expect(editor.markdownString == joined)
}
}
}
@Test @MainActor
func listNavigationSkipsHiddenSeparatorsAndKeepsSelectionAnchorsAndDeletionUndo() throws {
_ = NSApplication.shared
let suite = "NotesVisualCaret.\(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: 750, height: 390),
styleMask: .borderless, backing: .buffered, defer: false)
window.contentViewController = controller
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)
window.makeFirstResponder(editor)
let board = NSPasteboard.withUniqueName()
defer { board.releaseGlobally() }
let body = "🦊e\u{301} z"
// Prefix source, rendered prefix, marker, separator, backward/forward expansions.
let fixtures = [
("", "", "-", " ", "", ""),
("\t", "\t", "+", "\t", "", ""),
(" ", " ", "009)", "\t", "009", "09)"),
("- parent\n ", "\u{fffc} parent\n ", "- [X]", " ", "- [X", " [X]"),
("\t", "\t", "12)\t[]", "\t", "12)\t[", "2)\t[]"),
]
for (prefix, renderedPrefix, marker, separator, backwardExpansion, forwardExpansion) in fixtures {
let source = prefix + marker + separator + body
editor.insertText(source, replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
let rendered = renderedPrefix + "\u{fffc}" + separator + body
#expect(editor.string == rendered)
let before = renderedPrefix.utf16.count
let start = before + 1 + separator.utf16.count
func caret(_ index: Int) { editor.setSelectedRange(NSRange(location: index, length: 0)) }
func expectSelection(_ location: Int, _ length: Int = 0) {
#expect(editor.selectedRange() == NSRange(location: location, length: length))
}
caret(before)
editor.moveRight(nil)
expectSelection(start)
editor.moveRight(nil)
expectSelection(start + 2)
editor.moveRight(nil)
expectSelection(start + 4)
editor.moveRight(nil)
expectSelection(start + 5) // Visible body spaces still have individual stops.
editor.moveLeft(nil)
expectSelection(start + 4)
editor.moveLeft(nil)
expectSelection(start + 2)
editor.moveLeft(nil)
expectSelection(start)
editor.moveLeft(nil)
expectSelection(before)
if before > 0 {
editor.moveLeft(nil)
expectSelection(before - 1)
editor.moveRight(nil)
expectSelection(before)
}
editor.moveRightAndModifySelection(nil)
expectSelection(before, start - before)
#expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes))
#expect(board.string(forType: .string) == marker + separator)
editor.moveRightAndModifySelection(nil)
expectSelection(before, start + 2 - before)
editor.moveRightAndModifySelection(nil)
expectSelection(before, start + 4 - before)
editor.moveLeftAndModifySelection(nil)
expectSelection(before, start + 2 - before)
editor.moveLeftAndModifySelection(nil)
expectSelection(before, start - before)
editor.moveLeftAndModifySelection(nil)
expectSelection(before)
if before > 0 {
editor.moveLeftAndModifySelection(nil)
expectSelection(before - 1, 1)
editor.moveRightAndModifySelection(nil)
expectSelection(before)
}
caret(start)
editor.moveLeftAndModifySelection(nil)
expectSelection(before, start - before)
editor.moveRightAndModifySelection(nil)
expectSelection(start)
editor.moveRightAndModifySelection(nil)
expectSelection(start, 2)
editor.moveLeftAndModifySelection(nil)
expectSelection(start)
editor.moveLeftAndModifySelection(nil)
expectSelection(before, start - before)
editor.moveRightAndModifySelection(nil)
expectSelection(start)
editor.setSelectedRange(NSRange(location: 0, length: rendered.utf16.count))
board.clearContents()
#expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes))
#expect(board.string(forType: .string) == source)
#expect(controller.save())
let persisted = try NotesStore(directory: directory)
#expect(persisted.notes.contains { $0.contents.string == source })
for backward in [true, false] {
editor.setMarkdownContents(NSAttributedString(string: source))
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
caret(before)
if backward { editor.moveRight(nil); editor.deleteBackward(nil) }
else { editor.deleteForward(nil) }
editor.breakUndoCoalescing()
let expansion = backward ? backwardExpansion : forwardExpansion
#expect(editor.markdownString == prefix + expansion + separator + body)
#expect(editor.selectedRange() == NSRange(
location: before + (backward ? expansion.utf16.count : 0), length: 0))
editor.undoManager?.undo()
#expect(editor.string == rendered)
#expect(editor.markdownString == source)
#expect(editor.undoManager?.canUndo == false)
}
}
}
@Test @MainActor
func atomicTasksCopySelectAndPersistMarkdownAlongsideUnicodeAndImages() throws {
_ = NSApplication.shared
let suite = "NotesTaskImages.\(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 bitmap = try #require(NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
bitmap.setColor(.red, atX: 0, y: 0)
let png = try #require(bitmap.representation(using: .png, properties: [:]))
let imageFile = FileWrapper(regularFileWithContents: png)
imageFile.preferredFilename = "pixel.png"
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\n+ bullet\n009) numbered"))
let store = try NotesStore(directory: directory)
let id = try store.create()
try store.update(id: id, contents: source)
defaults.set(id.uuidString, forKey: "selectedNote")
let controller = NotesController(defaults: defaults, directory: directory)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 390),
styleMask: .borderless, backing: .buffered, defer: false)
window.contentViewController = controller
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)
window.makeFirstResponder(editor)
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)
let marker = (source.string as NSString).range(of: "12)\t[X]")
#expect(editor.markdownRange(for: token) == marker)
#expect(editor.renderedRange(for: (source.string as NSString).range(of: "[X]")) == token)
#expect(editor.renderedRange(for: (source.string as NSString).range(of: "🦊")) == (rendered as NSString).range(of: "🦊"))
editor.setSelectedRange(NSRange(location: token.location, length: 0))
editor.moveRight(nil)
#expect(editor.selectedRange() == NSRange(location: NSMaxRange(token) + 1, length: 0))
editor.moveLeft(nil)
#expect(editor.selectedRange() == NSRange(location: token.location, length: 0))
editor.moveRightAndModifySelection(nil)
#expect(editor.selectedRange() == NSRange(location: token.location, length: 2))
let board = NSPasteboard.withUniqueName()
defer { board.releaseGlobally() }
editor.setSelectedRange(token)
#expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes))
#expect(board.string(forType: .string) == "12)\t[X]")
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
board.clearContents()
#expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes))
#expect(board.string(forType: .string) == source.string)
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.string)
#expect((copied.attribute(.attachment, at: imageLocation, effectiveRange: nil) as? NSTextAttachment)?.fileWrapper?.regularFileContents == png)
editor.undoManager?.removeAllActions()
editor.setSelectedRange(token)
editor.deleteBackward(nil)
editor.breakUndoCoalescing()
#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)
#expect(editor.markdownString == source.string)
#expect(editor.undoManager?.canUndo == false)
#expect(controller.save())
let saved = try #require(NotesStore(directory: directory).notes.first { $0.id == id })
#expect(saved.contents.string == source.string)
var savedAttachments: [NSTextAttachment] = []
saved.contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: saved.contents.length)) { value, _, _ in
if let attachment = value as? NSTextAttachment { savedAttachments.append(attachment) }
}
#expect(savedAttachments.count == 1)
#expect(savedAttachments.first?.fileWrapper?.regularFileContents == png)
let reopened = NotesController(defaults: defaults, directory: directory)
defer { _ = reopened.close() }
let reopenedEditor = try #require(descendants(reopened.view).compactMap { $0 as? NotesTextView }.first)
#expect(reopenedEditor.string == rendered)
#expect(reopenedEditor.markdownContents.string == source.string)
}
@Test @MainActor
func taskTokensRequireTrailingWhitespaceAndAcceptEmptyBracketsWhileTyping() throws {
_ = NSApplication.shared
let suite = "TaskSyntax.\(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: 750, height: 390),
styleMask: .borderless, backing: .buffered, defer: false)
window.contentViewController = controller
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)
window.makeFirstResponder(editor)
for marker in ["- [ ]", "- []", "- [x]", "- [X]", "\t+ []", "12) []"] {
editor.setMarkdownContents(NSAttributedString(string: ""))
editor.setSelectedRange(NSRange(location: 0, length: 0))
var typed = ""
for character in marker {
typed.append(character)
editor.insertText(String(character), replacementRange: editor.selectedRange())
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" })
#expect(editor.string == indentation + "\u{fffc} ")
let checkbox = try #require(editor.subviews.compactMap { $0 as? NSButton }.first)
#expect(checkbox.state == (marker.hasSuffix("[x]") || marker.hasSuffix("[X]") ? .on : .off))
editor.insertText("🦊", replacementRange: editor.selectedRange())
#expect(editor.string == indentation + "\u{fffc} 🦊")
#expect(editor.markdownString == marker + " 🦊")
}
editor.setMarkdownContents(NSAttributedString(string: "- []\n"))
#expect(editor.string == "- []\n")
editor.setMarkdownContents(NSAttributedString(string: "- []\ttask"))
#expect(editor.string == "\u{fffc}\ttask")
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
let box = try #require(editor.subviews.compactMap { $0 as? NSButton }.first)
box.performClick(nil)
#expect(editor.markdownString == "- [x]\ttask")
#expect(box.state == .on)
editor.undoManager?.undo()
#expect(editor.markdownString == "- []\ttask")
#expect(box.state == .off)
editor.undoManager?.redo()
#expect(editor.markdownString == "- [x]\ttask")
box.performClick(nil)
#expect(editor.markdownString == "- [ ]\ttask")
let enter = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "\r",
charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36))
editor.insertText("- [] task", replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
editor.keyDown(with: enter)
#expect(editor.markdownString == "- [] task\n- [ ] ")
#expect(editor.string == "\u{fffc} task\n\u{fffc} ")
editor.keyDown(with: enter)
#expect(editor.markdownString == "- [] task\n")
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")
}
@Test @MainActor
func renderedListMarkersPaintBulletsAndNumbersInsideTheirGlyphFrames() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200))
editor.isRichText = true
for source in ["- item", "* item", "+ item", "1. item", "123) item"] {
editor.setMarkdownContents(NSAttributedString(string: source))
let layout = try #require(editor.layoutManager)
let container = try #require(editor.textContainer)
layout.ensureLayout(for: container)
let glyphs = layout.glyphRange(forCharacterRange: NSRange(location: 0, length: 1), actualCharacterRange: nil)
let frame = layout.boundingRect(forGlyphRange: glyphs, in: container)
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 160, pixelsHigh: 48,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap))
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = context
context.cgContext.clear(CGRect(x: 0, y: 0, width: 160, height: 48))
layout.drawGlyphs(forGlyphRange: glyphs, at: NSPoint(x: 4 - frame.minX, y: 10 - frame.minY))
NSGraphicsContext.restoreGraphicsState()
var painted = false
for y in 0..<48 {
for x in 0..<160 where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.1 {
painted = true
}
}
#expect(painted, "The actual TextKit draw path must paint the marker for \(source)")
#expect(editor.markdownString == source)
}
}
@Test @MainActor
func listBodyInsertionUsesVisiblePositionForTypingPasteAndReplacementRanges() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 520, height: 200))
editor.isRichText = true
editor.allowsUndo = true
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
window.contentView = editor
defer { window.orderOut(nil) }
window.makeFirstResponder(editor)
let board = NSPasteboard.withUniqueName()
defer { board.releaseGlobally() }
board.setString("pasted", forType: .string)
for marker in ["-", "*", "+", "1.", "123)", "- []", "- [ ]", "- [X]", "\t12) []"] {
let separator = " \t "
let original = marker + separator + "tail"
for offset in 0...separator.utf16.count {
for explicitRange in [false, true] {
editor.setMarkdownContents(NSAttributedString(string: original))
let token = (editor.string as NSString).range(of: "\u{fffc}")
let insertion = NSRange(location: NSMaxRange(token) + offset, length: 0)
editor.setSelectedRange(insertion)
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
editor.insertText("🦊", replacementRange: explicitRange ? insertion : NSRange(location: NSNotFound, length: 0))
let position = max(1, offset)
#expect(editor.markdownString == marker + String(separator.prefix(position)) + "🦊" + String(separator.dropFirst(position)) + "tail")
#expect((editor.string as NSString).range(of: "\u{fffc}").location != NSNotFound)
#expect(editor.selectedRange().location == NSMaxRange(token) + position + 2)
editor.breakUndoCoalescing()
editor.undoManager?.undo()
#expect(editor.markdownString == original)
editor.undoManager?.redo()
#expect(editor.markdownString == marker + String(separator.prefix(position)) + "🦊" + String(separator.dropFirst(position)) + "tail")
}
editor.setMarkdownContents(NSAttributedString(string: original))
let token = (editor.string as NSString).range(of: "\u{fffc}")
editor.setSelectedRange(NSRange(location: NSMaxRange(token) + offset, length: 0))
#expect(editor.readSelection(from: board))
let position = max(1, offset)
#expect(editor.markdownString == marker + String(separator.prefix(position)) + "pasted" + String(separator.dropFirst(position)) + "tail")
}
editor.setMarkdownContents(NSAttributedString(string: ""))
editor.setSelectedRange(NSRange(location: 0, length: 0))
for character in marker + " " {
editor.insertText(String(character), replacementRange: editor.selectedRange())
}
let token = (editor.string as NSString).range(of: "\u{fffc}")
#expect(editor.selectedRange().location == NSMaxRange(token) + 1)
editor.insertText("text", replacementRange: NSRange(location: NSNotFound, length: 0))
#expect(editor.markdownString == marker + " text")
}
editor.setMarkdownContents(NSAttributedString(string: "- ab🦊cd"))
let emoji = (editor.string as NSString).range(of: "🦊")
editor.insertText("X", replacementRange: emoji)
#expect(editor.markdownString == "- abXcd")
let separator = NSRange(location: 1, length: 3)
editor.insertText("!", replacementRange: separator)
#expect(editor.markdownString == "-!abXcd")
}
@Test @MainActor
func extraListBodyWhitespaceIsVisibleAndHasNormalCursorStops() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 520, height: 200))
editor.isRichText = true
editor.allowsUndo = true
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
window.contentView = editor
defer { window.orderOut(nil) }
window.makeFirstResponder(editor)
let layout = try #require(editor.layoutManager)
let container = try #require(editor.textContainer)
func bodyX() -> CGFloat {
layout.ensureLayout(for: container)
let range = (editor.string as NSString).range(of: "tail")
let glyph = layout.glyphIndexForCharacter(at: range.location)
return layout.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil).minX + layout.location(forGlyphAt: glyph).x
}
for marker in ["-", "+", "*", "12.", "99)", "- []", "- [x]", " - [ ]"] {
for whitespace in [" ", " ", "\t", "\t "] {
let source = marker + " tail"
editor.setMarkdownContents(NSAttributedString(string: source))
let token = (editor.string as NSString).range(of: "\u{fffc}")
let start = NSMaxRange(token) + 1
let originalX = bodyX()
editor.setSelectedRange(NSRange(location: start, length: 0))
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
editor.insertText(whitespace, replacementRange: editor.selectedRange())
#expect(editor.markdownString == marker + " " + whitespace + "tail")
#expect(bodyX() > originalX, "Additional body whitespace must advance the rendered text")
#expect(editor.selectedRange().location == start + whitespace.utf16.count)
editor.setSelectedRange(NSRange(location: token.location, length: 0))
editor.moveRight(nil)
#expect(editor.selectedRange().location == start)
for index in 1...whitespace.utf16.count {
editor.moveRight(nil)
#expect(editor.selectedRange().location == start + index)
}
for index in stride(from: whitespace.utf16.count - 1, through: 0, by: -1) {
editor.moveLeft(nil)
#expect(editor.selectedRange().location == start + index)
}
editor.moveLeft(nil)
#expect(editor.selectedRange().location == token.location)
editor.breakUndoCoalescing()
editor.undoManager?.undo()
#expect(editor.markdownString == source)
#expect(abs(bodyX() - originalX) < 0.5)
editor.undoManager?.redo()
#expect(bodyX() > originalX)
}
}
}
@Test @MainActor
func typingMarkersBeforeExistingWhitespaceWaitsForAnExplicitSeparator() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 520, height: 200))
editor.isRichText = true
editor.allowsUndo = true
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
window.contentView = editor
defer { window.orderOut(nil) }
window.makeFirstResponder(editor)
for marker in ["-", "*", "+", "12.", "9)", "- []", "- [x]"] {
for completion in [" ", "\t"] {
let prefix = "prose\n \t"
let tail = " \t"
editor.setMarkdownContents(NSAttributedString(string: prefix + tail))
editor.setSelectedRange(NSRange(location: prefix.utf16.count, length: 0))
for character in marker {
editor.insertText(String(character), replacementRange: editor.selectedRange())
}
#expect(editor.string == prefix + marker + tail)
#expect(editor.markdownString == prefix + marker + tail)
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
editor.insertText(completion, replacementRange: editor.selectedRange())
#expect(editor.string == prefix + "\u{fffc}" + completion + tail)
#expect(editor.markdownString == prefix + marker + completion + tail)
editor.breakUndoCoalescing()
editor.undoManager?.undo()
#expect(editor.string == prefix + marker + tail)
editor.undoManager?.redo()
#expect(editor.string == prefix + "\u{fffc}" + completion + tail)
editor.insertText("text", replacementRange: editor.selectedRange())
#expect(editor.markdownString == prefix + marker + completion + "text" + tail)
// Existing Markdown still renders on loading or pasting, without a typing gate.
editor.setMarkdownContents(NSAttributedString(string: marker + tail))
#expect(editor.string == "\u{fffc}" + tail)
editor.setMarkdownContents(NSAttributedString(string: tail))
editor.setSelectedRange(NSRange(location: 0, length: 0))
editor.insertText(NSAttributedString(string: marker + " pasted"), replacementRange: editor.selectedRange())
#expect(editor.string == "\u{fffc} pasted" + tail)
#expect(editor.markdownString == marker + " pasted" + tail)
}
}
}