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