227 lines
10 KiB
Swift
227 lines
10 KiB
Swift
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"),
|
|
("<u>word</u> 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)
|
|
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.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)
|
|
}
|