refactor: use structured rich text for Notes editing
This commit is contained in:
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user