refactor: use structured rich text for Notes editing

This commit is contained in:
2026-10-22 13:13:31 +02:00
parent 5803a00944
commit 751f22f6b1
20 changed files with 2155 additions and 2583 deletions
+155 -35
View File
@@ -16,6 +16,12 @@ private func vimKeys(_ keys: String, editor: NotesTextView) throws {
for character in keys { try vimKey(String(character), editor: editor) }
}
@MainActor
private func vimEventTurn() {
// Let AppKit close its ordinary event-scoped undo groups between key events.
CFRunLoopRunInMode(.defaultMode, 0.001, false)
}
@Test(arguments: [
("one two three", "dw", "two three"),
("one,two three", "dw", ",two three"),
@@ -52,11 +58,11 @@ private func vimKeys(_ keys: String, editor: NotesTextView) throws {
@MainActor func vimOperatorsRespectVisibleTextAndBoundaries(_ scenario: (String, String, String)) throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
editor.setMarkdownContents(NSAttributedString(string: scenario.0))
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: scenario.0)))
editor.vimEnabled = true
editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys(scenario.1, editor: editor)
#expect(editor.markdownString == scenario.2)
#expect(editor.documentContents.string == scenario.2)
#expect(editor.vimMode == (scenario.1.contains("c") ? .insert : .normal))
}
@@ -64,25 +70,29 @@ private func vimKeys(_ keys: String, editor: NotesTextView) throws {
func vimMotionsCountCharactersWordsAndLogicalLines() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 90, height: 400))
editor.setMarkdownContents(NSAttributedString(string: "## 🧑🏽‍💻é abc,def\nx\n long final line"))
let contents = NSMutableAttributedString(string: "🧑🏽‍💻é abc,def\nx\n long final line")
contents.addAttributes([NoteAttributes.block: "heading", NoteAttributes.headingLevel: 2],
range: (contents.string as NSString).paragraphRange(for: NSRange(location: 0, length: 0)))
editor.setDocumentContents(contents)
editor.vimEnabled = true
editor.setSelectedRange(NSRange(location: 3, length: 0))
editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys("l", editor: editor)
#expect(editor.selectedRange().location == 10) // One composed emoji, not seven UTF-16 units.
#expect(editor.selectedRange().location == 7) // One composed emoji, not seven UTF-16 units.
try vimKeys("l", editor: editor)
#expect(editor.selectedRange().location == 12) // Combining accent remains a single character.
#expect(editor.selectedRange().location == 9) // Combining accent remains a single character.
try vimKeys("w", editor: editor)
#expect(editor.selectedRange().location == 13)
#expect(editor.selectedRange().location == 10)
try vimKeys("e", editor: editor)
#expect(editor.selectedRange().location == 15)
#expect(editor.selectedRange().location == 12)
try vimKeys("w", editor: editor)
#expect(editor.selectedRange().location == 16)
try vimKeys("b", editor: editor)
#expect(editor.selectedRange().location == 13)
try vimKeys("b", editor: editor)
#expect(editor.selectedRange().location == 10)
try vimKeys("0", editor: editor)
#expect(editor.selectedRange().location == 3)
#expect(editor.selectedRange().location == 0)
try vimKeys("4ljj", editor: editor)
let final = (editor.string as NSString).range(of: " long final line").location
let finalRange = try #require(editor.string.range(of: " long final line"))
let final = NSRange(finalRange, in: editor.string).location
#expect(editor.selectedRange().location == final + 4) // Column survives a shorter line.
try vimKeys("^", editor: editor)
#expect(editor.selectedRange().location == final + 2)
@@ -91,13 +101,13 @@ func vimMotionsCountCharactersWordsAndLogicalLines() throws {
try vimKeys("G$", editor: editor)
#expect(editor.selectedRange().location == editor.string.utf16.count - 1)
try vimKeys("gg0", editor: editor)
#expect(editor.selectedRange().location == 3)
editor.setMarkdownContents(NSAttributedString(string: "one two"))
#expect(editor.selectedRange().location == 0)
editor.setDocumentContents(NSAttributedString(string: "one two"))
editor.setSelectedRange(NSRange(location: 0, length: 3))
try vimKeys("d", editor: editor)
#expect(editor.markdownString == " two") // Native mouse selections also accept Visual operators.
#expect(editor.documentContents.string == " two") // Native mouse selections also accept Visual operators.
try vimKey("ø", editor: editor, flags: .option)
#expect(editor.markdownString == " two") // Modified text cannot leak into Normal mode.
#expect(editor.documentContents.string == " two") // Modified text cannot leak into Normal mode.
}
@Test @MainActor
@@ -111,32 +121,37 @@ func vimYankPasteAndUndoPreserveRichAttachments() throws {
editor.isRichText = true
editor.allowsUndo = true
let attachment = NSTextAttachment()
let source = NSMutableAttributedString(string: "## Image ")
let source = NSMutableAttributedString(string: "Image ")
source.append(NSAttributedString(attachment: attachment))
source.append(NSAttributedString(string: "\nTail"))
editor.setMarkdownContents(source)
source.addAttributes([NoteAttributes.block: "heading", NoteAttributes.headingLevel: 2], range: NSRange(location: 0, length: 8))
source.addAttributes([NoteAttributes.bold: true, .underlineStyle: NSUnderlineStyle.single.rawValue], range: NSRange(location: 0, length: 5))
editor.setDocumentContents(source)
editor.vimEnabled = true
editor.setSelectedRange(NSRange(location: 3, length: 0))
editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys("yyp", editor: editor)
#expect(editor.markdownString == "## Image \u{fffc}\n## Image \u{fffc}\nTail")
let pasted = editor.markdownContents
#expect(pasted.attribute(.attachment, at: 20, effectiveRange: nil) as? NSTextAttachment === attachment)
#expect(editor.documentContents.string == "Image \u{fffc}\nImage \u{fffc}\nTail")
let pasted = editor.documentContents
#expect(pasted.attribute(.attachment, at: 14, effectiveRange: nil) as? NSTextAttachment === attachment)
#expect(pasted.attribute(NoteAttributes.headingLevel, at: 8, effectiveRange: nil) as? Int == 2)
#expect(pasted.attribute(NoteAttributes.bold, at: 8, effectiveRange: nil) as? Bool == true)
#expect(pasted.attribute(.underlineStyle, at: 8, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
try vimKeys("u", editor: editor)
#expect(editor.markdownString == source.string)
#expect(editor.documentContents.string == source.string)
try vimKey("r", editor: editor, flags: .control)
#expect(editor.markdownString == pasted.string)
editor.setMarkdownContents(NSAttributedString(string: "one two"))
#expect(editor.documentContents.string == pasted.string)
editor.setDocumentContents(NSAttributedString(string: "one two"))
editor.undoManager?.removeAllActions()
editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys("ywP", editor: editor)
#expect(editor.markdownString == "one one two")
#expect(editor.documentContents.string == "one one two")
try vimKeys("u", editor: editor)
#expect(editor.markdownString == "one two")
#expect(editor.documentContents.string == "one two")
editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys("cw", editor: editor)
try vimKeys("new", editor: editor)
try vimKey("\u{1b}", editor: editor, code: 53)
#expect(editor.markdownString == "new two")
#expect(editor.documentContents.string == "new two")
#expect(editor.vimMode == .normal)
#expect(editor.selectedRange().location == 2)
}
@@ -154,15 +169,120 @@ func vimInsertEntryAndLinewisePasteKeepParagraphsSeparate() throws {
("one", "o", "one\n"),
("one", "O", "\none")
] {
editor.setMarkdownContents(NSAttributedString(string: before))
editor.setDocumentContents(NSAttributedString(string: before))
editor.vimEnabled = true
editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys(keys, editor: editor)
#expect(editor.markdownString == after)
#expect(editor.documentContents.string == after)
if keys == "O" { #expect(editor.selectedRange().location == 0) }
}
}
@Test @MainActor
func vimDeletingFinalBoldCharacterOnlyDeletesThatCharacter() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
let contents = NSMutableAttributedString(string: "ab tail")
contents.addAttribute(NoteAttributes.bold, value: true, range: NSRange(location: 0, length: 2))
editor.setDocumentContents(contents)
editor.vimEnabled = true
editor.setSelectedRange(NSRange(location: 1, length: 0))
try vimKeys("x", editor: editor)
#expect(editor.documentContents.string == "a tail")
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
#expect(editor.vimState.register?.string == "b")
#expect(editor.vimState.register?.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
try vimKeys("P", editor: editor)
#expect(editor.documentContents.string == "ab tail")
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 1, effectiveRange: nil) as? Bool == true)
}
@Test @MainActor
func vimCharacterRegisterPreservesNativeImageAttributes() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
let attachment = NSTextAttachment()
let imageBytes = Data([0, 1, 2, 3])
attachment.fileWrapper = FileWrapper(regularFileWithContents: imageBytes)
let contents = NSMutableAttributedString(attributedString: NSAttributedString(attachment: attachment))
contents.append(NSAttributedString(string: " tail"))
editor.setDocumentContents(contents)
editor.vimEnabled = true
editor.setSelectedRange(NSRange(location: 0, length: 0))
try vimKeys("ylp", editor: editor)
#expect(editor.documentContents.string == "\u{fffc}\u{fffc} tail")
let pasted = try #require(editor.documentContents.attribute(.attachment, at: 1, effectiveRange: nil) as? NSTextAttachment)
#expect(pasted.fileWrapper?.regularFileContents == imageBytes)
}
@Test(arguments: ["escape", "focus", "toggle", "reset"])
@MainActor func vimChangeAndTypingUndoTogetherAcrossEvents(_ ending: String) throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
window.contentView = editor
window.makeFirstResponder(editor)
defer { window.orderOut(nil) }
editor.allowsUndo = true
let original = NSMutableAttributedString(string: "one two")
original.addAttribute(NoteAttributes.bold, value: true, range: NSRange(location: 0, length: 3))
editor.setDocumentContents(original)
editor.vimEnabled = true
editor.setSelectedRange(NSRange(location: 0, length: 0))
let undo = try #require(editor.undoManager)
undo.removeAllActions()
try vimKeys("cw", editor: editor)
vimEventTurn()
for character in "new" {
try vimKey(String(character), editor: editor)
vimEventTurn()
}
#expect(editor.documentContents.string == "new two")
switch ending {
case "escape": try vimKey("\u{1b}", editor: editor, code: 53)
case "focus": #expect(window.makeFirstResponder(nil))
case "toggle": editor.vimEnabled = false
default: editor.resetVim()
}
vimEventTurn()
#expect(undo.canUndo)
undo.undo()
#expect(editor.documentContents.string == original.string)
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
#expect(!undo.canUndo)
undo.redo()
#expect(editor.documentContents.string == "new two")
}
@Test @MainActor
func vimSeparateInsertionSessionsHaveSeparateUndoOperations() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
window.contentView = editor
window.makeFirstResponder(editor)
defer { window.orderOut(nil) }
editor.allowsUndo = true
editor.setDocumentContents(NSAttributedString(string: "tail"))
editor.vimEnabled = true
editor.setSelectedRange(NSRange(location: 0, length: 0))
let undo = try #require(editor.undoManager)
undo.removeAllActions()
try vimKeys("ione ", editor: editor)
vimEventTurn()
#expect(editor.documentContents.string == "one tail")
#expect(editor.vimMode == .insert)
try vimKey("\u{1b}", editor: editor, code: 53)
try vimKeys("0itwo ", editor: editor)
vimEventTurn()
try vimKey("\u{1b}", editor: editor, code: 53)
#expect(editor.documentContents.string == "two one tail")
try vimKeys("u", editor: editor)
#expect(editor.documentContents.string == "one tail")
try vimKeys("u", editor: editor)
#expect(editor.documentContents.string == "tail")
}
@Test @MainActor
func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws {
_ = NSApplication.shared
@@ -185,7 +305,7 @@ func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws {
let toggle = try #require(descendants(content).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Toggle Vim editing" })
#expect(!editor.vimEnabled)
try vimKeys("hello", editor: editor)
#expect(editor.markdownString == "hello")
#expect(editor.documentContents.string == "hello")
toggle.performClick(nil)
#expect(defaults.bool(forKey: "notesVimEnabled"))
#expect(editor.vimMode == .normal)
@@ -204,9 +324,9 @@ func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws {
window.sendEvent(escape)
#expect(window.firstResponder === editor)
#expect(window.isVisible)
let beforeMotion = editor.markdownString
let beforeMotion = editor.documentContents.string
try vimKeys("w", editor: editor)
#expect(editor.markdownString == beforeMotion)
#expect(editor.documentContents.string == beforeMotion)
try vimKeys("d", editor: editor)
window.sendEvent(escape)
#expect(!window.isVisible)
@@ -215,9 +335,9 @@ func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws {
let reopened = try #require(descendants(reopenedContent).compactMap { $0 as? NotesTextView }.first)
#expect(reopened.vimEnabled)
#expect(reopened.vimMode == .normal)
let beforeReopenedMotion = reopened.markdownString
let beforeReopenedMotion = reopened.documentContents.string
try vimKeys("w", editor: reopened)
#expect(reopened.markdownString == beforeReopenedMotion)
#expect(reopened.documentContents.string == beforeReopenedMotion)
let newToggle = try #require(descendants(reopenedContent).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Toggle Vim editing" })
newToggle.performClick(nil)
#expect(!defaults.bool(forKey: "notesVimEnabled"))