feat: add modal Vim editing to notes

This commit is contained in:
2026-10-22 11:25:41 +02:00
parent 665691ddee
commit 615cbd6eb6
7 changed files with 781 additions and 5 deletions
+225
View File
@@ -0,0 +1,225 @@
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)
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)
}