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) } } @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.setMarkdownContents(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.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)) editor.setMarkdownContents(NSAttributedString(string: "## πŸ§‘πŸ½β€πŸ’»é abc,def\nx\n long final line")) editor.vimEnabled = true editor.setSelectedRange(NSRange(location: 3, length: 0)) try vimKeys("l", editor: editor) #expect(editor.selectedRange().location == 10) // One composed emoji, not seven UTF-16 units. try vimKeys("l", editor: editor) #expect(editor.selectedRange().location == 12) // Combining accent remains a single character. try vimKeys("w", editor: editor) #expect(editor.selectedRange().location == 13) try vimKeys("e", editor: editor) #expect(editor.selectedRange().location == 15) try vimKeys("w", editor: editor) #expect(editor.selectedRange().location == 16) try vimKeys("b", editor: editor) #expect(editor.selectedRange().location == 13) try vimKeys("0", editor: editor) #expect(editor.selectedRange().location == 3) try vimKeys("4ljj", editor: editor) let final = (editor.string as NSString).range(of: " long final line").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 == 3) editor.setMarkdownContents(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. try vimKey("ΓΈ", editor: editor, flags: .option) #expect(editor.markdownString == " 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")) editor.setMarkdownContents(source) editor.vimEnabled = true editor.setSelectedRange(NSRange(location: 3, 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) try vimKeys("u", editor: editor) #expect(editor.markdownString == source.string) try vimKey("r", editor: editor, flags: .control) #expect(editor.markdownString == pasted.string) editor.setMarkdownContents(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") try vimKeys("u", editor: editor) #expect(editor.markdownString == "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.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.setMarkdownContents(NSAttributedString(string: before)) editor.vimEnabled = true editor.setSelectedRange(NSRange(location: 0, length: 0)) try vimKeys(keys, editor: editor) #expect(editor.markdownString == after) if keys == "O" { #expect(editor.selectedRange().location == 0) } } } @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.markdownString == "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.markdownString try vimKeys("w", editor: editor) #expect(editor.markdownString == beforeMotion) try vimKeys("d", editor: editor) launcher.dismiss(restoringFocus: false) 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.markdownString try vimKeys("w", editor: reopened) #expect(reopened.markdownString == 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) }