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