347 lines
16 KiB
Swift
347 lines
16 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) }
|
|
}
|
|
|
|
@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"),
|
|
("<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.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)
|
|
}
|