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
+25
View File
@@ -194,6 +194,31 @@ Embedded images display with 8-point rounded corners. Rounding only clips their
Notes autosave locally as RTFD packages, including embedded images, under `~/Library/Application Support/Conjure/Notes`. No account, network access, or cloud sync is involved. Save failures appear below the editor and prevent switching, closing, or quitting until saving succeeds, preserving the unsaved buffer. The Close button and Command-W dismiss Conjure, including any open popup; leaving Notes or switching destinations removes its popup completely and restores the launcher’s original dimensions.
### Vim editing
Click **Vim Off** at the left of the Notes header to enable modal editing. The button shows **NORMAL**, **INSERT**, **VISUAL**, or **VISUAL LINE**; click it again to disable Vim. This preference persists across reopening and app restarts. Opening or switching notes starts in Normal mode when enabled; ordinary editing is unchanged when disabled.
| Operation | Keys |
| --- | --- |
| Move by visible character / logical line | `h` / `l`, `j` / `k` (or arrow keys) |
| Next word / previous word / word end | `w` / `b` / `e`; uppercase `W` / `B` / `E` treats punctuation as part of a word |
| Line start / first nonblank / line end | `0` / `^` / `$` |
| First / last line; numbered line | `gg` / `G`; `12gg` or `12G` |
| Insert before / after; first nonblank / line end | `i` / `a`; `I` / `A` |
| Open a line below / above | `o` / `O` |
| Characterwise / linewise selection | `v` / `V`; repeat to leave that selection mode |
| Delete / change / yank | `d` / `c` / `y` followed by a motion, or applied to a Visual selection |
| Delete / change / yank whole lines | `dd` / `cc` / `yy` |
| Delete characters; delete / change through line end | `x`; `D` / `C` |
| Paste after / before | `p` / `P` |
| Native Undo / Redo | `u` / Control-R; Command-Z / Command-Shift-Z remain available |
| Return to Normal / cancel a pending command | Escape or Control-[ |
Counts work before motions and operators, including `3j`, `2dd`, and `2d3w`. Vertical movement uses logical lines rather than wrapped display rows and retains the desired column across shorter lines. Character motions and selections respect composed Unicode characters and skip hidden Markdown syntax. Whole-token operators carry recognized inline formatting delimiters with their visible text; linewise operations include heading and list prefixes. Yank/delete/change use a local unnamed register, preserving attributed text and embedded images for `p`/`P` without replacing the system clipboard. Editing and Undo still use the native text view and RTFD autosaves.
Escape closes an open picker first; otherwise, with Vim enabled, it returns to Normal without dismissing Notes. Command-W still dismisses the window. Command shortcuts, formatting controls, and native Insert-mode input remain available. Pending commands cancel on focus changes and Command shortcuts. This is a supported Vim subset, not an embedded Vim: search, text objects, named registers, macros, and dot-repeat are not implemented.
## Translation
Search for **Conjure Translate**, identified by its centered 🌏 icon, and open it. With empty or whitespace-only source text, Return or **Swap languages** reverses the source and target without submitting a request. **MyMemory** translates the text in one step: press Return or click **Translate** to submit. Typing alone never sends text.
@@ -1338,6 +1338,10 @@ final class LauncherWindow: NSWindow {
override func sendEvent(_ event: NSEvent) {
if event.type == .keyDown, event.keyCode == 53, attachedSheet == nil,
event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty {
if let editor = firstResponder as? NotesTextView, editor.vimEnabled, editor.hasMarkedText() {
super.sendEvent(event)
return
}
cancelOperation(nil)
return
}
+53 -3
View File
@@ -22,6 +22,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
private let editor = NotesTextView()
private let status = NSTextField(labelWithString: "")
private let noteTitle = NSTextField(labelWithString: "Untitled Note")
private let vimButton = NSButton(title: "Vim Off", target: nil, action: nil)
private let formatting = NSStackView()
private static let formattingKeys: [(key: String, modifiers: NSEvent.ModifierFlags)] = [
("h", [.command, .shift]), ("b", .command), ("i", .command),
@@ -64,14 +65,25 @@ final class NotesController: NSViewController, NSTextViewDelegate {
noteTitle.lineBreakMode = .byTruncatingTail
noteTitle.setAccessibilityLabel("Note title")
let header = NSView()
for control in [noteTitle] {
vimButton.target = self
vimButton.action = #selector(toggleVim)
vimButton.bezelStyle = .accessoryBarAction
vimButton.font = .monospacedSystemFont(ofSize: 10, weight: .medium)
vimButton.contentTintColor = SunsetPalette.secondaryText
vimButton.setAccessibilityLabel("Toggle Vim editing")
vimButton.toolTip = "Toggle Vim editing. Escape: Normal mode; i: Insert; v: Visual; Command-W: close."
editor.vimModeChanged = { [weak self] in self?.updateVimIndicator() }
editor.vimEnabled = defaults.bool(forKey: "notesVimEnabled")
for control in [noteTitle, vimButton] {
control.translatesAutoresizingMaskIntoConstraints = false
header.addSubview(control)
control.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true
}
NSLayoutConstraint.activate([
noteTitle.centerXAnchor.constraint(equalTo: header.centerXAnchor),
noteTitle.leadingAnchor.constraint(greaterThanOrEqualTo: header.leadingAnchor, constant: 44),
vimButton.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
vimButton.widthAnchor.constraint(equalToConstant: 90),
noteTitle.leadingAnchor.constraint(greaterThanOrEqualTo: vimButton.trailingAnchor, constant: 8),
noteTitle.trailingAnchor.constraint(lessThanOrEqualTo: header.trailingAnchor, constant: -44),
])
actions.closed = { [weak self] in self?.focusEditor() }
@@ -190,6 +202,18 @@ final class NotesController: NSViewController, NSTextViewDelegate {
showError(error)
}
}
@objc private func toggleVim() {
editor.vimEnabled.toggle()
defaults.set(editor.vimEnabled, forKey: "notesVimEnabled")
focusEditor()
}
private func updateVimIndicator() {
vimButton.title = editor.vimEnabled ? editor.vimMode.rawValue : "Vim Off"
vimButton.setAccessibilityValue(editor.vimEnabled ? editor.vimMode.rawValue : "Off")
vimButton.state = editor.vimEnabled ? .on : .off
}
private func configureIcon(_ button: NSButton, symbol: String, label: String) {
button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)?
@@ -490,7 +514,11 @@ final class NotesController: NSViewController, NSTextViewDelegate {
}
func handle(_ selector: Selector) -> Bool {
actions.isShown && actions.handle(selector)
if actions.isShown { return actions.handle(selector) }
if selector == #selector(NSResponder.cancelOperation(_:)), view.window?.firstResponder === editor {
return editor.handleVimEscape()
}
return false
}
func handleControlKey(_ key: String) -> Bool {
@@ -502,6 +530,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
func handleCommandKey(_ event: NSEvent) -> Bool {
guard store != nil, view.window?.attachedSheet == nil else { return false }
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
if modifiers.contains(.command) { editor.clearVimCommand() }
let key = event.charactersIgnoringModifiers?.lowercased() ?? ""
if let index = Self.formattingKeys.firstIndex(where: { $0.key == key && $0.modifiers == modifiers }) {
guard editor.isEditable, !actions.isShown, view.window?.firstResponder === editor,
@@ -728,6 +757,27 @@ enum MarkdownHighlighting {
}
contents.removeAttribute(codeBackground, range: range)
}
// Whole visible tokens carry their delimiters through Vim delete/yank/change operations.
static func completeInlineRange(_ selection: NSRange, in contents: NSAttributedString) -> NSRange {
var range = selection
let full = NSRange(location: 0, length: contents.length)
let matches = ([inlineCode] + rules.map(\.0)).flatMap { $0.matches(in: contents.string, range: full) }
var previous: NSRange
repeat {
previous = range
for match in matches {
let body = match.range(withName: "body")
guard body.location != NSNotFound, body.length > 0,
body.location > match.range.location, NSMaxRange(body) < NSMaxRange(match.range),
range.location <= body.location, NSMaxRange(range) >= NSMaxRange(body),
contents.attribute(hiddenMarkup, at: match.range.location, effectiveRange: nil) as? Bool == true
else { continue }
range = NSUnionRange(range, match.range)
}
} while range != previous
return range
}
static func apply(to editor: NSTextView) {
guard let storage = editor.textStorage else { return }
+20 -1
View File
@@ -3,7 +3,7 @@ import AppKit
private let literalTaskMarker = NSAttributedString.Key("Conjure.literalTaskMarker")
private let sourceTaskAttachment = NSAttributedString.Key("Conjure.sourceTaskAttachment")
private let sourceListParagraph = NSAttributedString.Key("Conjure.sourceListParagraph")
private let listWhitespace = NSAttributedString.Key("Conjure.listWhitespace")
let listWhitespace = NSAttributedString.Key("Conjure.listWhitespace")
private final class ListAttachment: NSTextAttachment {
let source: NSAttributedString
@@ -61,12 +61,18 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
private var tasks: [(range: NSRange, attachment: ListAttachment)] = []
private var checkboxes: [NSButton] = []
private var horizontalSelection: (anchor: Int, head: Int)?
var vimState = NotesVimState()
var vimModeChanged: (() -> Void)?
var vimEnabled = false {
didSet { resetVim() }
}
var markdownContents: NSAttributedString { Self.serialize(attributedString()) }
var markdownString: String { markdownContents.string }
func setMarkdownContents(_ contents: NSAttributedString) {
horizontalSelection = nil
resetVim()
textStorage?.setAttributedString(Self.render(contents))
refreshTasks()
}
@@ -232,6 +238,7 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
}
override func keyDown(with event: NSEvent) {
if handleVimKey(event) { return }
if event.keyCode == 48,
event.modifierFlags.intersection([.command, .control, .option, .shift]) == .shift,
delegate?.textView?(self, doCommandBy: #selector(NSResponder.insertBacktab(_:))) == true {
@@ -240,6 +247,18 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
super.keyDown(with: event)
}
override func resignFirstResponder() -> Bool {
let resigned = super.resignFirstResponder()
if resigned { clearVimCommand() }
return resigned
}
override func mouseDown(with event: NSEvent) {
if vimEnabled && vimMode != .insert { resetVim() }
else { clearVimCommand() }
super.mouseDown(with: event)
}
override func insertText(_ insertString: Any, replacementRange: NSRange) {
let replacement: NSAttributedString
if let attributed = insertString as? NSAttributedString {
+448
View File
@@ -0,0 +1,448 @@
import AppKit
enum NotesVimMode: String {
case normal = "NORMAL", insert = "INSERT", visual = "VISUAL", visualLine = "VISUAL LINE"
}
struct NotesVimState {
var mode: NotesVimMode = .normal
var count = 0
var operatorCount = 1
var pending: Character?
var awaitingG = false
var anchor: Int?
var head: Int?
var column: Int?
var register: NSAttributedString?
var linewiseRegister = false
}
// ponytail: project the whole note for each command; cache visible offsets if large notes make motions slow.
@MainActor
private struct VimBuffer {
var characters: [Character] = []
var ranges: [NSRange] = []
let length: Int
init(_ contents: NSAttributedString) {
length = contents.length
var offset = 0
for character in contents.string {
let size = String(character).utf16.count
let hidden = contents.attribute(MarkdownHighlighting.hiddenMarkup, at: offset, effectiveRange: nil) as? Bool == true
|| contents.attribute(listWhitespace, at: offset, effectiveRange: nil) as? Bool == true
if !hidden {
characters.append(character)
ranges.append(NSRange(location: offset, length: size))
}
offset += size
}
}
var count: Int { characters.count }
func offset(_ index: Int) -> Int { index < count ? ranges[max(0, index)].location : length }
func index(_ offset: Int) -> Int { ranges.firstIndex { NSMaxRange($0) > offset } ?? count }
func newline(_ index: Int) -> Bool { index < count && characters[index].isNewline }
func lineStart(_ index: Int) -> Int {
var result = min(index, count)
while result > 0 && !newline(result - 1) { result -= 1 }
return result
}
func lineEnd(_ index: Int) -> Int {
var result = min(index, count)
while result < count && !newline(result) { result += 1 }
return result
}
func normal(_ index: Int) -> Int {
let index = min(max(0, index), count)
let start = lineStart(index), end = lineEnd(index)
return end > start ? min(index, end - 1) : start
}
func firstNonblank(_ index: Int) -> Int {
var result = lineStart(index)
let end = lineEnd(index)
while result < end && characters[result].isWhitespace { result += 1 }
return normal(result)
}
func wordKind(_ index: Int, big: Bool) -> Int {
guard index < count else { return 0 }
let character = characters[index]
if character.isWhitespace { return 0 }
if big { return 1 }
return character.isLetter || character.isNumber || character == "_" ? 1 : 2
}
func word(_ origin: Int, key: Character) -> Int {
let big = key.isUppercase
var index = min(origin, count)
if key == "b" || key == "B" {
if index > 0 { index -= 1 }
while index > 0 && wordKind(index, big: big) == 0 { index -= 1 }
let kind = wordKind(index, big: big)
while index > 0 && wordKind(index - 1, big: big) == kind { index -= 1 }
} else if key == "w" || key == "W" {
let kind = wordKind(index, big: big)
while index < count && wordKind(index, big: big) == kind { index += 1 }
while index < count && wordKind(index, big: big) == 0 { index += 1 }
} else {
if index < count { index += 1 }
while index < count && wordKind(index, big: big) == 0 { index += 1 }
let kind = wordKind(index, big: big)
while index + 1 < count && wordKind(index + 1, big: big) == kind { index += 1 }
index = min(index, max(0, count - 1))
}
return index
}
}
extension NotesTextView {
var vimMode: NotesVimMode { vimState.mode }
func resetVim() {
let register = vimState.register, linewise = vimState.linewiseRegister
vimState = NotesVimState()
vimState.register = register
vimState.linewiseRegister = linewise
vimModeChanged?()
}
func clearVimCommand() {
vimState.count = 0
vimState.operatorCount = 1
vimState.pending = nil
vimState.awaitingG = false
}
private func setVimMode(_ mode: NotesVimMode) {
breakUndoCoalescing()
clearVimCommand()
vimState.mode = mode
vimState.anchor = nil
vimState.head = nil
vimState.column = nil
vimModeChanged?()
}
@discardableResult
func handleVimEscape() -> Bool {
guard vimEnabled, !hasMarkedText() else { return false }
let buffer = VimBuffer(attributedString())
var head = vimState.head ?? buffer.index(selectedRange().location)
if vimState.mode == .insert && head > buffer.lineStart(head) { head -= 1 }
setVimMode(.normal)
setVimCaret(buffer.normal(head), buffer: buffer)
return true
}
private func setVimCaret(_ index: Int, buffer: VimBuffer) {
setSelectedRange(NSRange(location: buffer.offset(index), length: 0), affinity: .downstream, stillSelecting: false)
scrollRangeToVisible(selectedRange())
}
private func showVimSelection(_ head: Int, buffer: VimBuffer) {
let anchor = vimState.anchor ?? head
vimState.head = head
let low = min(anchor, head), high = max(anchor, head)
let range: NSRange
if vimState.mode == .visualLine {
let start = buffer.offset(buffer.lineStart(low))
let end = buffer.lineEnd(high)
let stop = buffer.offset(min(buffer.count, end + (buffer.newline(end) ? 1 : 0)))
range = NSRange(location: start, length: stop - start)
} else {
let start = buffer.offset(low)
let stop = high < buffer.count ? NSMaxRange(buffer.ranges[high]) : buffer.length
range = NSRange(location: start, length: stop - start)
}
setSelectedRange(range)
scrollRangeToVisible(NSRange(location: buffer.offset(head), length: 0))
}
func handleVimKey(_ event: NSEvent) -> Bool {
guard vimEnabled, isEditable, !hasMarkedText() else { return false }
let flags = event.modifierFlags.intersection([.command, .control, .option, .shift])
// Command shortcuts remain native; Option text input belongs only to Insert mode.
if flags.contains(.command) { clearVimCommand(); return false }
if flags.contains(.option) { clearVimCommand(); return vimState.mode != .insert }
if event.keyCode == 53 { return handleVimEscape() }
if flags == .control {
if event.charactersIgnoringModifiers == "[" { return handleVimEscape() }
if vimState.mode != .insert && event.charactersIgnoringModifiers?.lowercased() == "r" {
clearVimCommand()
undoManager?.redo()
normalizeVimCaret()
return true
}
return vimState.mode != .insert
}
guard vimState.mode != .insert else { return false }
let key: Character
switch event.keyCode {
case 123: key = "h"
case 124: key = "l"
case 125: key = "j"
case 126: key = "k"
case 51: key = "h"
case 36, 76: key = "j"
default:
guard let text = event.characters, text.count == 1, let character = text.first else { return true }
key = character
}
if let digit = key.wholeNumberValue, digit < 10, digit > 0 || vimState.count > 0 {
// Saturate before arithmetic; a held numeric key must not overflow or create unbounded work.
vimState.count = min(9999, vimState.count * 10 + digit)
return true
}
let buffer = VimBuffer(attributedString())
if vimState.mode == .normal, selectedRange().length > 0 {
vimState.mode = .visual
vimState.anchor = buffer.index(selectedRange().location)
vimState.head = max(vimState.anchor!, buffer.index(NSMaxRange(selectedRange())) - 1)
vimModeChanged?()
}
let origin = buffer.normal(vimState.head ?? buffer.index(selectedRange().location))
let count = min(9999, max(1, vimState.count) * vimState.operatorCount)
let visual = vimState.mode == .visual || vimState.mode == .visualLine
if vimState.awaitingG {
vimState.awaitingG = false
guard key == "g" else { clearVimCommand(); return true }
let target = vimLine(count: count, buffer: buffer)
finishVimMotion(origin: origin, target: target, inclusive: false, linewise: true, buffer: buffer)
return true
}
if key == "g" { vimState.awaitingG = true; return true }
if "dcy".contains(key) {
if visual {
let range = vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine)
applyVimOperator(key, range: range, linewise: vimState.mode == .visualLine)
} else if vimState.pending == key {
var end = buffer.lineEnd(origin)
for _ in 1..<count where buffer.newline(end) { end = buffer.lineEnd(end + 1) }
applyVimLineOperator(key, start: origin, end: end, buffer: buffer)
} else if vimState.pending == nil {
vimState.pending = key
vimState.operatorCount = max(1, vimState.count)
vimState.count = 0
} else { clearVimCommand() }
return true
}
if "hljkwWbBeE0^$G".contains(key) {
var target = origin
var inclusive = false
let linewise = key == "j" || key == "k" || key == "G"
switch key {
case "h": target = max(buffer.lineStart(origin), origin - count)
case "l": target = min(buffer.lineEnd(origin) - (vimState.pending == nil ? 1 : 0), origin + count)
target = max(buffer.lineStart(origin), target)
case "j", "k":
let column = vimState.column ?? (origin - buffer.lineStart(origin))
vimState.column = column
var start = buffer.lineStart(origin)
for _ in 0..<count {
if key == "j" {
let end = buffer.lineEnd(start)
if buffer.newline(end) { start = end + 1 }
} else if start > 0 { start = buffer.lineStart(start - 1) }
}
target = buffer.normal(min(start + column, buffer.lineEnd(start)))
case "0": target = buffer.lineStart(origin)
case "^": target = buffer.firstNonblank(origin)
case "$":
var end = buffer.lineEnd(origin)
for _ in 1..<count where buffer.newline(end) { end = buffer.lineEnd(end + 1) }
target = max(buffer.lineStart(end), end - 1)
inclusive = true
case "G": target = vimState.count > 0 || vimState.operatorCount > 1
? vimLine(count: count, buffer: buffer) : buffer.firstNonblank(buffer.count)
default:
var motion = key
var repetitions = count
// cw includes this word's end even when the cursor is already on its last character.
if vimState.pending == "c", (key == "w" || key == "W"), buffer.wordKind(origin, big: key == "W") != 0 {
motion = key == "w" ? "e" : "E"
let kind = buffer.wordKind(origin, big: key == "W")
while target + 1 < buffer.count && buffer.wordKind(target + 1, big: key == "W") == kind { target += 1 }
repetitions -= 1
}
for step in 0..<repetitions {
let previous = target
target = buffer.word(target, key: motion)
if step == repetitions - 1, vimState.pending != nil, motion == "w" || motion == "W",
buffer.wordKind(previous, big: motion == "W") != 0, target > buffer.lineEnd(previous) {
target = buffer.lineEnd(previous)
}
}
inclusive = motion == "e" || motion == "E"
}
if key != "j" && key != "k" { vimState.column = nil }
finishVimMotion(origin: origin, target: target, inclusive: inclusive, linewise: linewise, buffer: buffer)
return true
}
guard vimState.pending == nil else { clearVimCommand(); return true }
clearVimCommand()
switch key {
case "v", "V":
let mode: NotesVimMode = key == "v" ? .visual : .visualLine
if vimState.mode == mode { setVimMode(.normal); setVimCaret(origin, buffer: buffer) }
else {
let anchor = vimState.anchor ?? origin
setVimMode(mode)
vimState.anchor = anchor
showVimSelection(origin, buffer: buffer)
}
case "i", "a", "I", "A":
guard !visual else { return true }
var target = origin
if key == "a" && origin < buffer.lineEnd(origin) { target += 1 }
if key == "I" { target = buffer.firstNonblank(origin) }
if key == "A" { target = buffer.lineEnd(origin) }
setVimMode(.insert)
setVimCaret(target, buffer: buffer)
case "o", "O":
guard !visual else { return true }
let target = key == "o" ? buffer.lineEnd(origin) : buffer.lineStart(origin)
setVimMode(.insert)
setVimCaret(target, buffer: buffer)
insertNewline(nil)
if key == "O" {
let updated = VimBuffer(attributedString())
let caret = updated.index(selectedRange().location)
setVimCaret(updated.lineStart(max(0, caret - 1)), buffer: updated)
}
case "x":
if visual { applyVimOperator("d", range: vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine), linewise: vimState.mode == .visualLine) }
else {
let end = min(buffer.lineEnd(origin), origin + count)
let startOffset = buffer.offset(origin), endOffset = buffer.offset(end)
if end > origin { applyVimOperator("d", range: vimSourceRange(for: NSRange(location: startOffset, length: endOffset - startOffset)), linewise: false) }
}
case "D", "C":
var end = buffer.lineEnd(origin)
for _ in 1..<count where buffer.newline(end) { end = buffer.lineEnd(end + 1) }
let startOffset = buffer.offset(origin), endOffset = buffer.offset(end)
applyVimOperator(key == "D" ? "d" : "c", range: vimSourceRange(for: NSRange(location: startOffset, length: endOffset - startOffset)), linewise: false)
case "p", "P": pasteVimRegister(before: key == "P", count: count, origin: origin, buffer: buffer)
case "u":
setVimMode(.normal)
for _ in 0..<count where undoManager?.canUndo == true { undoManager?.undo() }
normalizeVimCaret()
default: break // Unsupported Normal-mode keys never become note text.
}
return true
}
private func vimLine(count: Int, buffer: VimBuffer) -> Int {
var start = 0
for _ in 1..<count {
let end = buffer.lineEnd(start)
if buffer.newline(end) { start = end + 1 }
}
return buffer.firstNonblank(start)
}
private func finishVimMotion(origin: Int, target: Int, inclusive: Bool, linewise: Bool, buffer: VimBuffer) {
if let operation = vimState.pending {
if linewise { applyVimLineOperator(operation, start: min(origin, target), end: max(origin, target), buffer: buffer) }
else {
let low = min(origin, target)
let high = min(buffer.count, max(origin, target) + (inclusive ? 1 : 0))
let start = buffer.offset(low), end = buffer.offset(high)
// A motion against a boundary is a no-op, not a character deletion.
if end > start { applyVimOperator(operation, range: vimSourceRange(for: NSRange(location: start, length: end - start)), linewise: false) }
else { clearVimCommand() }
}
} else {
clearVimCommand()
if vimState.mode == .visual || vimState.mode == .visualLine { showVimSelection(buffer.normal(target), buffer: buffer) }
else { setVimCaret(buffer.normal(target), buffer: buffer) }
}
}
private func vimSourceRange(for rendered: NSRange, linewise: Bool = false) -> NSRange {
let range = markdownRange(for: MarkdownHighlighting.completeInlineRange(rendered, in: attributedString()))
return linewise ? (markdownString as NSString).lineRange(for: range) : range
}
private func applyVimLineOperator(_ operation: Character, start: Int, end: Int, buffer: VimBuffer) {
let source = markdownString as NSString
let startOffset = markdownRange(for: NSRange(location: buffer.offset(buffer.lineStart(start)), length: 0)).location
let endOffset = markdownRange(for: NSRange(location: buffer.offset(buffer.lineEnd(end)), length: 0)).location
let first = source.lineRange(for: NSRange(location: startOffset, length: 0)).location
let last = NSMaxRange(source.lineRange(for: NSRange(location: endOffset, length: 0)))
applyVimOperator(operation, range: NSRange(location: first, length: last - first), linewise: true)
}
private func applyVimOperator(_ operation: Character, range: NSRange, linewise: Bool) {
let source = markdownContents
let register = NSMutableAttributedString(attributedString: source.attributedSubstring(from: range))
if linewise && !register.string.hasSuffix("\n") { register.append(NSAttributedString(string: "\n")) }
vimState.register = register
vimState.linewiseRegister = linewise
let originalCaret = selectedRange().location
setVimMode(operation == "c" ? .insert : .normal)
if operation == "y" {
setSelectedRange(NSRange(location: originalCaret, length: 0))
normalizeVimCaret()
return
}
var deletion = range
var replacement = ""
if linewise {
let text = source.string as NSString
if operation == "c" {
// cc keeps the following line separate while opening a blank line for insertion.
if NSMaxRange(range) < text.length { replacement = "\n" }
} else if NSMaxRange(range) == text.length, range.location > 0 {
let previous = text.rangeOfComposedCharacterSequence(at: range.location - 1)
if text.substring(with: previous).allSatisfy(\.isNewline) {
deletion.location = previous.location
deletion.length += previous.length
}
}
}
replaceMarkdown(replacement, range: deletion)
if operation == "c" {
setSelectedRange(renderedRange(for: NSRange(location: deletion.location, length: 0)))
} else { normalizeVimCaret() }
}
private func pasteVimRegister(before: Bool, count: Int, origin: Int, buffer: VimBuffer) {
guard let register = vimState.register else { return }
let insertion = NSMutableAttributedString(string: "")
for _ in 0..<count { insertion.append(register) }
var range: NSRange
if vimState.mode == .visual || vimState.mode == .visualLine {
range = vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine)
} else if vimState.linewiseRegister {
let source = markdownString as NSString
let position = markdownRange(for: NSRange(location: buffer.offset(origin), length: 0)).location
let line = source.lineRange(for: NSRange(location: position, length: 0))
let offset = before ? line.location : NSMaxRange(line)
range = NSRange(location: offset, length: 0)
if offset == source.length, offset == 0 || !source.substring(from: offset - 1).allSatisfy(\.isNewline) {
if offset > 0 {
insertion.insert(NSAttributedString(string: "\n"), at: 0)
}
if insertion.string.hasSuffix("\n") { insertion.deleteCharacters(in: NSRange(location: insertion.length - 1, length: 1)) }
}
} else {
let index = before ? origin : min(buffer.lineEnd(origin), origin + 1)
range = markdownInsertionRange(for: NSRange(location: buffer.offset(index), length: 0))
}
setVimMode(.normal)
replaceMarkdown(insertion, range: range)
let caret = vimState.linewiseRegister ? range.location + (insertion.string.hasPrefix("\n") ? 1 : 0) : range.location + insertion.length
setSelectedRange(renderedRange(for: NSRange(location: caret, length: 0)))
if !vimState.linewiseRegister {
let updated = VimBuffer(attributedString())
setVimCaret(updated.normal(max(0, updated.index(selectedRange().location) - 1)), buffer: updated)
} else { normalizeVimCaret() }
}
private func normalizeVimCaret() {
let buffer = VimBuffer(attributedString())
setVimCaret(buffer.normal(buffer.index(selectedRange().location)), buffer: buffer)
vimState.anchor = nil
vimState.head = nil
}
}
+6 -1
View File
@@ -23,4 +23,9 @@
- add cmd+shift+s for strikethrough and cmd+shift+u for underline
- don't hide the backticks surrounding a codeblock
- give inline codeblocks (e.g. `some code snippet`) an inline container and an orange text color matching the color scheme of the conjure application
- [ ] feature: vim motions
- [x] feature: vim motions
- opt-in Normal/Insert/Visual editing, counted motions, delete/change/yank, rich-text paste, and native Undo/Redo.
- [ ] feature: configurable color scheme
- general design should stay consistent
- only colors should be configurable; not the way the colors are used/presented (e.g. frosted glass effect with color blobs behind shouldn't change)
- this should play nicely together with the theme control tool in `/Users/valdemar/Developer/Personal/Tools/tint`.
+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)
}