feat: add modal Vim editing to notes
This commit is contained in:
@@ -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.
|
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
|
## 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.
|
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) {
|
override func sendEvent(_ event: NSEvent) {
|
||||||
if event.type == .keyDown, event.keyCode == 53, attachedSheet == nil,
|
if event.type == .keyDown, event.keyCode == 53, attachedSheet == nil,
|
||||||
event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty {
|
event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty {
|
||||||
|
if let editor = firstResponder as? NotesTextView, editor.vimEnabled, editor.hasMarkedText() {
|
||||||
|
super.sendEvent(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
cancelOperation(nil)
|
cancelOperation(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
private let editor = NotesTextView()
|
private let editor = NotesTextView()
|
||||||
private let status = NSTextField(labelWithString: "")
|
private let status = NSTextField(labelWithString: "")
|
||||||
private let noteTitle = NSTextField(labelWithString: "Untitled Note")
|
private let noteTitle = NSTextField(labelWithString: "Untitled Note")
|
||||||
|
private let vimButton = NSButton(title: "Vim Off", target: nil, action: nil)
|
||||||
private let formatting = NSStackView()
|
private let formatting = NSStackView()
|
||||||
private static let formattingKeys: [(key: String, modifiers: NSEvent.ModifierFlags)] = [
|
private static let formattingKeys: [(key: String, modifiers: NSEvent.ModifierFlags)] = [
|
||||||
("h", [.command, .shift]), ("b", .command), ("i", .command),
|
("h", [.command, .shift]), ("b", .command), ("i", .command),
|
||||||
@@ -64,14 +65,25 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
noteTitle.lineBreakMode = .byTruncatingTail
|
noteTitle.lineBreakMode = .byTruncatingTail
|
||||||
noteTitle.setAccessibilityLabel("Note title")
|
noteTitle.setAccessibilityLabel("Note title")
|
||||||
let header = NSView()
|
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
|
control.translatesAutoresizingMaskIntoConstraints = false
|
||||||
header.addSubview(control)
|
header.addSubview(control)
|
||||||
control.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true
|
control.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true
|
||||||
}
|
}
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
noteTitle.centerXAnchor.constraint(equalTo: header.centerXAnchor),
|
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),
|
noteTitle.trailingAnchor.constraint(lessThanOrEqualTo: header.trailingAnchor, constant: -44),
|
||||||
])
|
])
|
||||||
actions.closed = { [weak self] in self?.focusEditor() }
|
actions.closed = { [weak self] in self?.focusEditor() }
|
||||||
@@ -190,6 +202,18 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
showError(error)
|
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) {
|
private func configureIcon(_ button: NSButton, symbol: String, label: String) {
|
||||||
button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)?
|
button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)?
|
||||||
@@ -490,7 +514,11 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handle(_ selector: Selector) -> Bool {
|
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 {
|
func handleControlKey(_ key: String) -> Bool {
|
||||||
@@ -502,6 +530,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
func handleCommandKey(_ event: NSEvent) -> Bool {
|
func handleCommandKey(_ event: NSEvent) -> Bool {
|
||||||
guard store != nil, view.window?.attachedSheet == nil else { return false }
|
guard store != nil, view.window?.attachedSheet == nil else { return false }
|
||||||
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
||||||
|
if modifiers.contains(.command) { editor.clearVimCommand() }
|
||||||
let key = event.charactersIgnoringModifiers?.lowercased() ?? ""
|
let key = event.charactersIgnoringModifiers?.lowercased() ?? ""
|
||||||
if let index = Self.formattingKeys.firstIndex(where: { $0.key == key && $0.modifiers == modifiers }) {
|
if let index = Self.formattingKeys.firstIndex(where: { $0.key == key && $0.modifiers == modifiers }) {
|
||||||
guard editor.isEditable, !actions.isShown, view.window?.firstResponder === editor,
|
guard editor.isEditable, !actions.isShown, view.window?.firstResponder === editor,
|
||||||
@@ -728,6 +757,27 @@ enum MarkdownHighlighting {
|
|||||||
}
|
}
|
||||||
contents.removeAttribute(codeBackground, range: range)
|
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) {
|
static func apply(to editor: NSTextView) {
|
||||||
guard let storage = editor.textStorage else { return }
|
guard let storage = editor.textStorage else { return }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import AppKit
|
|||||||
private let literalTaskMarker = NSAttributedString.Key("Conjure.literalTaskMarker")
|
private let literalTaskMarker = NSAttributedString.Key("Conjure.literalTaskMarker")
|
||||||
private let sourceTaskAttachment = NSAttributedString.Key("Conjure.sourceTaskAttachment")
|
private let sourceTaskAttachment = NSAttributedString.Key("Conjure.sourceTaskAttachment")
|
||||||
private let sourceListParagraph = NSAttributedString.Key("Conjure.sourceListParagraph")
|
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 {
|
private final class ListAttachment: NSTextAttachment {
|
||||||
let source: NSAttributedString
|
let source: NSAttributedString
|
||||||
@@ -61,12 +61,18 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
private var tasks: [(range: NSRange, attachment: ListAttachment)] = []
|
private var tasks: [(range: NSRange, attachment: ListAttachment)] = []
|
||||||
private var checkboxes: [NSButton] = []
|
private var checkboxes: [NSButton] = []
|
||||||
private var horizontalSelection: (anchor: Int, head: Int)?
|
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 markdownContents: NSAttributedString { Self.serialize(attributedString()) }
|
||||||
var markdownString: String { markdownContents.string }
|
var markdownString: String { markdownContents.string }
|
||||||
|
|
||||||
func setMarkdownContents(_ contents: NSAttributedString) {
|
func setMarkdownContents(_ contents: NSAttributedString) {
|
||||||
horizontalSelection = nil
|
horizontalSelection = nil
|
||||||
|
resetVim()
|
||||||
textStorage?.setAttributedString(Self.render(contents))
|
textStorage?.setAttributedString(Self.render(contents))
|
||||||
refreshTasks()
|
refreshTasks()
|
||||||
}
|
}
|
||||||
@@ -232,6 +238,7 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func keyDown(with event: NSEvent) {
|
override func keyDown(with event: NSEvent) {
|
||||||
|
if handleVimKey(event) { return }
|
||||||
if event.keyCode == 48,
|
if event.keyCode == 48,
|
||||||
event.modifierFlags.intersection([.command, .control, .option, .shift]) == .shift,
|
event.modifierFlags.intersection([.command, .control, .option, .shift]) == .shift,
|
||||||
delegate?.textView?(self, doCommandBy: #selector(NSResponder.insertBacktab(_:))) == true {
|
delegate?.textView?(self, doCommandBy: #selector(NSResponder.insertBacktab(_:))) == true {
|
||||||
@@ -240,6 +247,18 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
super.keyDown(with: event)
|
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) {
|
override func insertText(_ insertString: Any, replacementRange: NSRange) {
|
||||||
let replacement: NSAttributedString
|
let replacement: NSAttributedString
|
||||||
if let attributed = insertString as? 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,4 +23,9 @@
|
|||||||
- add cmd+shift+s for strikethrough and cmd+shift+u for underline
|
- add cmd+shift+s for strikethrough and cmd+shift+u for underline
|
||||||
- don't hide the backticks surrounding a codeblock
|
- 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
|
- 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`.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user