feat: add modal Vim editing to notes
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user