import AppKit import ConjureKit import Testing @testable import Conjure @MainActor private func vimKey(_ text: String, editor: NotesTextView, flags: NSEvent.ModifierFlags = [], code: UInt16 = 0) throws { let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 1, windowNumber: editor.window?.windowNumber ?? 0, context: nil, characters: text, charactersIgnoringModifiers: text, isARepeat: false, keyCode: code)) editor.keyDown(with: event) } @MainActor 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"), ("one,two three", "dW", "three"), ("one two three four five", "2d2w", "five"), ("one two", "cw", " two"), ("a two", "cw", " two"), ("one two", "ce", " two"), ("one two", "d$", ""), ("one two", "ldh", "ne two"), ("one\ntwo\nthree", "dd", "two\nthree"), ("one\ntwo\nthree", "jdd", "one\nthree"), ("one\ntwo\nthree", "Gdd", "one\ntwo"), ("one\ntwo\nthree", "dj", "three"), ("one\ntwo\nthree", "dG", ""), ("one\ntwo\nthree", "Gdgg", ""), ("one two", "vld", "e two"), ("one two", "llvhd", "o two"), ("one\ntwo\nthree", "Vjd", "three"), ("## Title\nBody", "dd", "Body"), ("## Title\nBody", "Vd", "Body"), ("## Title\nBody", "Vjd", ""), ("a two three", "c2w", " three"), ("one\ntwo", "dw", "\ntwo"), ("one", "$dl", "on"), ("**bold** next", "dw", "next"), ("word next", "cw", " next"), ("[label](https://example.com) next", "de", " next"), ("- [ ] task\nnext", "dd", "next"), ("πŸ§‘πŸ½β€πŸ’»é!", "x", "é!"), ("one two", "dh", "one two"), ("one two", "dq", "one two") ]) @MainActor func vimOperatorsRespectVisibleTextAndBoundaries(_ scenario: (String, String, String)) throws { _ = NSApplication.shared let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) 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.documentContents.string == scenario.2) #expect(editor.vimMode == (scenario.1.contains("c") ? .insert : .normal)) } @Test @MainActor func vimMotionsCountCharactersWordsAndLogicalLines() throws { _ = NSApplication.shared let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 90, height: 400)) 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: 0, length: 0)) try vimKeys("l", editor: editor) #expect(editor.selectedRange().location == 7) // One composed emoji, not seven UTF-16 units. try vimKeys("l", editor: editor) #expect(editor.selectedRange().location == 9) // Combining accent remains a single character. try vimKeys("w", editor: editor) #expect(editor.selectedRange().location == 10) try vimKeys("e", editor: editor) #expect(editor.selectedRange().location == 12) try vimKeys("w", 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 == 0) try vimKeys("4ljj", editor: editor) 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) try vimKeys("2gg", editor: editor) #expect(editor.selectedRange().location == (editor.string as NSString).range(of: "\nx\n").location + 1) try vimKeys("G$", editor: editor) #expect(editor.selectedRange().location == editor.string.utf16.count - 1) try vimKeys("gg0", editor: editor) #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.documentContents.string == " two") // Native mouse selections also accept Visual operators. try vimKey("ΓΈ", editor: editor, flags: .option) #expect(editor.documentContents.string == " two") // Modified text cannot leak into Normal mode. } @Test @MainActor func vimYankPasteAndUndoPreserveRichAttachments() 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.isRichText = true editor.allowsUndo = true let attachment = NSTextAttachment() let source = NSMutableAttributedString(string: "Image ") source.append(NSAttributedString(attachment: attachment)) source.append(NSAttributedString(string: "\nTail")) 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: 0, length: 0)) try vimKeys("yyp", editor: editor) #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.documentContents.string == source.string) try vimKey("r", editor: editor, flags: .control) #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.documentContents.string == "one one two") try vimKeys("u", editor: editor) #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.documentContents.string == "new two") #expect(editor.vimMode == .normal) #expect(editor.selectedRange().location == 2) } @Test @MainActor func vimInsertEntryAndLinewisePasteKeepParagraphsSeparate() throws { _ = NSApplication.shared let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) for (before, keys, after) in [ ("one\ntwo", "cc", "\ntwo"), ("one\ntwo", "Gcc", "one\n"), ("one\ntwo", "yyGp", "one\ntwo\none"), ("one\ntwo", "yyP", "one\none\ntwo"), ("one\n", "yyp", "one\none\n"), ("one", "o", "one\n"), ("one", "O", "\none") ] { editor.setDocumentContents(NSAttributedString(string: before)) editor.vimEnabled = true editor.setSelectedRange(NSRange(location: 0, length: 0)) try vimKeys(keys, editor: editor) #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 let suite = "NotesVim.\(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 launcher = LauncherWindowController( history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }), defaults: defaults, notesDirectory: directory) launcher.present(command: .notes) defer { launcher.dismiss(restoringFocus: false) } let window = try #require(launcher.window) func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } let content = try #require(window.contentView) let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first) 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.documentContents.string == "hello") toggle.performClick(nil) #expect(defaults.bool(forKey: "notesVimEnabled")) #expect(editor.vimMode == .normal) try vimKeys("i!", editor: editor) let escape = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53)) window.sendEvent(escape) #expect(editor.vimMode == .normal) #expect(window.isVisible) let browse = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: .command, timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "p", charactersIgnoringModifiers: "p", isARepeat: false, keyCode: 35)) try vimKeys("d", editor: editor) #expect(window.performKeyEquivalent(with: browse)) window.sendEvent(escape) #expect(window.firstResponder === editor) #expect(window.isVisible) let beforeMotion = editor.documentContents.string try vimKeys("w", editor: editor) #expect(editor.documentContents.string == beforeMotion) try vimKeys("d", editor: editor) window.sendEvent(escape) #expect(!window.isVisible) launcher.present(command: .notes) let reopenedContent = try #require(window.contentView) let reopened = try #require(descendants(reopenedContent).compactMap { $0 as? NotesTextView }.first) #expect(reopened.vimEnabled) #expect(reopened.vimMode == .normal) let beforeReopenedMotion = reopened.documentContents.string try vimKeys("w", editor: reopened) #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")) window.sendEvent(escape) #expect(!window.isVisible) }