Files

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)
}