refactor: use structured rich text for Notes editing

This commit is contained in:
2026-10-22 13:13:31 +02:00
parent 5803a00944
commit 751f22f6b1
20 changed files with 2155 additions and 2583 deletions
+7 -429
View File
@@ -1,6 +1,5 @@
import AppKit
import ConjureKit
import Highlighter
import UniformTypeIdentifiers
@MainActor
@@ -236,66 +235,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
@objc private func formatNote(_ sender: NSButton) {
guard editor.isEditable else { return }
let contents = editor.markdownContents
let source = contents.string as NSString
var selection = editor.markdownInsertionRange(for: editor.selectedRange())
if sender.tag == 6 {
selection = source.lineRange(for: selection)
if selection.length > 0, source.character(at: NSMaxRange(selection) - 1) == 10 { selection.length -= 1 }
}
let wrappers = [1: ("**", "**"), 2: ("*", "*"), 3: ("~~", "~~"), 4: ("`", "`"),
5: ("[", "](url)"), 6: ("```\n", "\n```"), 11: ("<u>", "</u>")]
if let (open, close) = wrappers[sender.tag] {
let before = selection.location - open.utf16.count
let after = NSMaxRange(selection)
var stars = 0
if open == "*" {
while selection.location > stars, after + stars < source.length,
source.character(at: selection.location - stars - 1) == 42,
source.character(at: after + stars) == 42 { stars += 1 }
}
if before >= 0, after + close.utf16.count <= source.length,
source.substring(with: NSRange(location: before, length: open.utf16.count)) == open,
source.substring(with: NSRange(location: after, length: close.utf16.count)) == close,
open != "*" || stars % 2 == 1 {
let body = contents.attributedSubstring(from: selection)
selection = NSRange(location: before, length: selection.length + open.utf16.count + close.utf16.count)
replaceMarkdown(body, range: selection, selection: NSRange(location: before, length: body.length))
} else {
let body = NSMutableAttributedString(string: open, attributes: editor.typingAttributes)
body.append(contents.attributedSubstring(from: selection))
body.append(NSAttributedString(string: close, attributes: editor.typingAttributes))
replaceMarkdown(body, range: selection,
selection: NSRange(location: selection.location + open.utf16.count, length: selection.length))
}
} else {
let prefixes = [0: "# ", 7: "> ", 8: "1. ", 9: "- ", 10: "- [ ] "]
guard let prefix = prefixes[sender.tag] else { return }
let length = selection.length > 0 && source.character(at: NSMaxRange(selection) - 1) == 10
? selection.length - 1 : selection.length
let lines = source.lineRange(for: NSRange(location: selection.location, length: length))
let body = NSMutableAttributedString(attributedString: contents.attributedSubstring(from: lines))
let rows = body.string.components(separatedBy: "\n")
var offset = body.length, firstDelta = 0
for (index, line) in rows.enumerated().reversed() {
offset -= line.utf16.count + (index < rows.count - 1 ? 1 : 0)
if index == rows.count - 1, index > 0, line.isEmpty { continue }
let indent = String(line.prefix { $0 == " " || $0 == "\t" })
let content = String(line.dropFirst(indent.count))
let item = Self.listMatch(in: content as NSString)
let kind = item.map { $0.range(at: 6).location != NSNotFound ? 10 : $0.range(at: 3).location != NSNotFound ? 8 : 9 }
let removing = kind == sender.tag || (item == nil && content.hasPrefix(prefix))
let oldPrefix = item?.range.length ?? (removing ? prefix.utf16.count : 0)
let newPrefix = removing ? "" : sender.tag == 8 ? "\(index + 1). " : prefix
body.replaceCharacters(in: NSRange(location: offset + indent.utf16.count, length: oldPrefix),
with: NSAttributedString(string: newPrefix, attributes: editor.typingAttributes))
if index == 0 { firstDelta = newPrefix.utf16.count - oldPrefix }
}
let start = max(lines.location, selection.location + firstDelta)
let end = max(start, NSMaxRange(selection) + body.length - lines.length)
replaceMarkdown(body, range: lines, selection: NSRange(location: start, length: end - start))
}
editor.format(sender.tag)
focusEditor()
}
@@ -306,203 +246,12 @@ final class NotesController: NSViewController, NSTextViewDelegate {
_ = save()
}
private static let listItem = try! NSRegularExpression(
pattern: "^([ \\t]*)([-+*]|([0-9]+)([.)]))([ \\t]+|$)(?:\\[([ xX]?)\\]([ \\t]+|$))?"
)
private static func listMatch(in line: NSString) -> NSTextCheckingResult? {
guard let item = listItem.firstMatch(in: line as String, range: NSRange(location: 0, length: line.length)) else { return nil }
let marker = line.substring(with: item.range(at: 2))
// Spaced thematic breaks are not list items or list ancestors.
if marker == "-" || marker == "*" {
let characters = (line as String).lazy.filter { !$0.isWhitespace }
let bullet = marker.first!
if characters.count >= 3, characters.allSatisfy({ $0 == bullet }) { return nil }
}
return item
}
private static func indentationWidth(_ indentation: String) -> Int {
indentation.reduce(0) { $1 == "\t" ? $0 + 4 - $0 % 4 : $0 + 1 }
}
private static func outdentLength(_ indentation: String) -> Int {
indentation.hasPrefix("\t") ? 1 : indentation.prefix(2).prefix { $0 == " " }.count
}
private static func continuedPrefix(_ item: NSTextCheckingResult, in line: NSString) -> String? {
var marker = line.substring(with: item.range(at: 2))
if item.range(at: 3).location != NSNotFound {
guard let number = UInt64(line.substring(with: item.range(at: 3))), number < UInt64.max else { return nil }
marker = "\(number + 1)" + line.substring(with: item.range(at: 4))
}
var prefix = line.substring(with: item.range(at: 1)) + marker + line.substring(with: item.range(at: 5))
if item.range(at: 6).location != NSNotFound {
prefix += "[ ]" + line.substring(with: item.range(at: 7))
}
return prefix
}
private func setSelectionWithUndo(_ selection: NSRange) {
let previous = editor.selectedRange()
editor.undoManager?.registerUndo(withTarget: self) { $0.setSelectionWithUndo(previous) }
editor.setSelectedRange(selection)
}
private func replaceListMarkdown(_ replacement: String, range: NSRange, selection: NSRange) {
replaceMarkdown(NSAttributedString(string: replacement, attributes: editor.typingAttributes), range: range, selection: selection)
}
private func replaceMarkdown(_ replacement: NSAttributedString, range: NSRange, selection: NSRange) {
editor.undoManager?.beginUndoGrouping()
// Native text undo restores its diff boundary, not the user's selection.
setSelectionWithUndo(editor.selectedRange())
editor.replaceMarkdown(replacement, range: range)
setSelectionWithUndo(editor.renderedRange(for: selection))
editor.undoManager?.endUndoGrouping()
}
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
let newline = commandSelector == #selector(NSResponder.insertNewline(_:))
let indenting = commandSelector == #selector(NSResponder.insertTab(_:))
let outdenting = commandSelector == #selector(NSResponder.insertBacktab(_:))
guard textView === editor, editor.isEditable, newline || indenting || outdenting else { return false }
let source = editor.markdownString as NSString
let selection = editor.markdownInsertionRange(for: editor.selectedRange())
var start = 0, end = 0
source.getLineStart(&start, end: nil, contentsEnd: &end,
for: NSRange(location: selection.location, length: 0))
let line = source.substring(with: NSRange(location: start, length: end - start)) as NSString
guard let item = Self.listMatch(in: line), NSMaxRange(selection) <= end else { return false }
let codeBlocks = MarkdownHighlighting.codeBlocks(in: source as String)
guard !codeBlocks.contains(where: {
NSIntersectionRange($0.range, NSRange(location: start, length: end - start)).length > 0
}) else { return false }
let indent = line.substring(with: item.range(at: 1))
if indenting {
let marker = editor.renderedRange(for:
NSRange(location: start + item.range(at: 2).location, length: 0)).location
// A literal marker still needs native Tab to complete its separator.
guard editor.textStorage?.attribute(.attachment, at: marker, effectiveRange: nil) is NSTextAttachment else { return false }
}
if indenting || outdenting {
let removed = outdenting ? Self.outdentLength(indent) : 0
guard indenting || removed > 0 else { return true }
let replacement = indenting ? " " : ""
let delta = replacement.utf16.count - removed
var selectedStart = max(start, selection.location + delta)
var selectedEnd = max(start, NSMaxRange(selection) + delta)
func isListLine(_ range: NSRange) -> Bool {
Self.listMatch(in: source.substring(with: range) as NSString) != nil
&& !codeBlocks.contains { NSIntersectionRange($0.range, range).length > 0 }
}
var block = source.lineRange(for: NSRange(location: start, length: 0))
while block.location > 0 {
let previous = source.lineRange(for: NSRange(location: block.location - 1, length: 0))
guard isListLine(previous) else { break }
block = NSUnionRange(previous, block)
}
while NSMaxRange(block) < source.length {
let next = source.lineRange(for: NSRange(location: NSMaxRange(block), length: 0))
guard isListLine(next) else { break }
block = NSUnionRange(block, next)
}
let body = NSMutableAttributedString(attributedString: editor.markdownContents.attributedSubstring(from: block))
body.replaceCharacters(in: NSRange(location: start - block.location, length: removed), with: replacement)
let changed = body.string as NSString
let oldDepth = Self.indentationWidth(indent)
let newDepth = Self.indentationWidth(replacement + (indent as NSString).substring(from: removed))
var ancestors: [(depth: Int, number: UInt64?, delimiter: String)] = []
var edits: [(range: NSRange, number: String)] = []
var oldLevel = true, newLevel = true
var offset = 0
while offset < changed.length {
let row = changed.lineRange(for: NSRange(location: offset, length: 0))
let text = changed.substring(with: row) as NSString
guard let match = Self.listMatch(in: text) else { break }
let depth = Self.indentationWidth(text.substring(with: match.range(at: 1)))
if offset + block.location > start {
if depth < oldDepth { oldLevel = false }
if depth < newDepth { newLevel = false }
}
while let last = ancestors.last, last.depth > depth { ancestors.removeLast() }
let peer = ancestors.last?.depth == depth ? ancestors.removeLast() : nil
var number: UInt64?
var delimiter = ""
if match.range(at: 3).location != NSNotFound {
number = UInt64(text.substring(with: match.range(at: 3)))
delimiter = text.substring(with: match.range(at: 4))
if offset + block.location >= start,
(oldLevel && depth == oldDepth) || (newLevel && depth == newDepth) {
if let peer, peer.delimiter == delimiter, let previous = peer.number, previous < UInt64.max {
number = previous + 1
} else if !ancestors.isEmpty || offset + block.location > start {
number = 1
}
if let number, UInt64(text.substring(with: match.range(at: 3))) != number {
let digits = match.range(at: 3)
edits.append((NSRange(location: offset + digits.location, length: digits.length), String(number)))
}
}
}
ancestors.append((depth, number, delimiter))
offset = NSMaxRange(row)
}
for edit in edits.reversed() {
body.replaceCharacters(in: edit.range, with: edit.number)
let location = block.location + edit.range.location
let difference = edit.number.utf16.count - edit.range.length
func adjusted(_ position: Int) -> Int {
if position <= location { return position }
if position < location + edit.range.length { return location + min(position - location, edit.number.utf16.count) }
return position + difference
}
selectedStart = adjusted(selectedStart)
selectedEnd = adjusted(selectedEnd)
}
replaceMarkdown(body, range: block,
selection: NSRange(location: selectedStart, length: selectedEnd - selectedStart))
return true
}
let markerEnd = item.range(at: 6).location == NSNotFound
? NSMaxRange(item.range(at: 2)) : NSMaxRange(item.range(at: 6)) + 1
guard selection.location >= start + markerEnd else { return false }
let body = line.substring(from: item.range.length)
if body.trimmingCharacters(in: .whitespaces).isEmpty {
var replacement = indent.isEmpty ? "" : line.substring(from: Self.outdentLength(indent))
var previous = start
let depth = Self.indentationWidth(indent)
while depth > 0, previous > 0 {
let range = source.lineRange(for: NSRange(location: previous - 1, length: 0))
previous = range.location
let ancestor = source.substring(with: range) as NSString
if let parent = Self.listMatch(in: ancestor),
!codeBlocks.contains(where: { NSIntersectionRange($0.range, range).length > 0 }) {
let parentIndent = ancestor.substring(with: parent.range(at: 1))
if Self.indentationWidth(parentIndent) < depth {
replacement = Self.continuedPrefix(parent, in: ancestor) ?? ""
break
}
} else if !(ancestor as String).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let leading = (ancestor as String).prefix { $0 == " " || $0 == "\t" }
if Self.indentationWidth(String(leading)) < depth { break }
}
}
replaceListMarkdown(replacement, range: NSRange(location: start, length: end - start),
selection: NSRange(location: start + replacement.utf16.count, length: 0))
} else {
guard let prefix = Self.continuedPrefix(item, in: line) else { return false }
replaceListMarkdown("\n" + prefix, range: selection,
selection: NSRange(location: selection.location + 1 + prefix.utf16.count, length: 0))
}
return true
}
@discardableResult
func save() -> Bool {
guard isDirty, let store, let selectedID else { return true }
do {
try store.update(id: selectedID, contents: editor.markdownContents)
try store.update(id: selectedID, contents: editor.documentContents)
isDirty = false
noteTitle.stringValue = store.notes.first { $0.id == selectedID }?.title ?? "Untitled Note"
status.isHidden = true
@@ -530,7 +279,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() }
if modifiers.contains(.command) { editor.endInsertionSession(); 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,
@@ -571,7 +320,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
private func copyAsMarkdown() {
actions.close(animated: false)
do {
let export = try NoteMarkdownExport(contents: editor.markdownContents)
let export = try NoteMarkdownExport(contents: editor.documentContents)
let clipboard = NSPasteboard.general
clipboard.clearContents()
guard clipboard.setString(export.clipboardText, forType: .string) else {
@@ -584,7 +333,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
guard let window = view.window, window.attachedSheet == nil else { return }
actions.close(animated: false)
do {
let export = try NoteMarkdownExport(contents: editor.markdownContents)
let export = try NoteMarkdownExport(contents: editor.documentContents)
let panel = NSSavePanel()
panel.title = "Export as Markdown"
panel.prompt = "Export"
@@ -635,7 +384,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
try store.delete(id: id)
self.selectedID = nil
self.defaults.removeObject(forKey: "selectedNote")
self.editor.setMarkdownContents(NSAttributedString(string: ""))
self.editor.setDocumentContents(NSAttributedString(string: ""))
self.editor.undoManager?.removeAllActions()
self.editor.isEditable = false
self.noteTitle.stringValue = "Untitled Note"
@@ -654,7 +403,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
guard let note = store?.notes.first(where: { $0.id == id }) else { return }
selectedID = id
defaults.set(id.uuidString, forKey: "selectedNote")
editor.setMarkdownContents(note.contents)
editor.setDocumentContents(note.contents)
editor.undoManager?.removeAllActions()
editor.isEditable = true
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
@@ -694,174 +443,3 @@ private final class NotesView: NSView {
}
}
private final class RoundedImageAttachmentCell: NSTextAttachmentCell {
override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
NSBezierPath(roundedRect: cellFrame, xRadius: 8, yRadius: 8).addClip()
super.draw(withFrame: cellFrame, in: controlView)
}
}
@MainActor
enum MarkdownHighlighting {
// ponytail: whole-note highlighting; incremental parsing if large notes make typing slow.
static let hiddenMarkup = NSAttributedString.Key("Conjure.hiddenMarkup")
static let codeBackground = NSAttributedString.Key("Conjure.codeBackground")
private static let inlineCode = try! NSRegularExpression(pattern: "(?<![\\\\`])(?<marker>`+)(?<body>[^`\\n]+)\\k<marker>(?!`)")
private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = [
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>\\*{2,3}|_{2,3})(?<body>\\S(?:[^\\n]*?\\S)?)\\k<marker>(?![*_])"), [.font: NSFont.monospacedSystemFont(ofSize: NotesAppearance.font.pointSize, weight: .bold)]),
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>[*_])(?<body>[^\\s*_](?:[^*_\\n]*?[^\\s*_])?)\\k<marker>(?![*_])"), [.font: NSFontManager.shared.convert(NotesAppearance.font, toHaveTrait: .italicFontMask)]),
(try! NSRegularExpression(pattern: "(?<!\\\\)!?\\[(?<body>[^\\]\\n]+)\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: SunsetPalette.accent]),
(try! NSRegularExpression(pattern: "(?<!\\\\)~~(?<body>\\S(?:[^\\n]*?\\S)?)~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]),
(try! NSRegularExpression(pattern: "(?<!\\\\)<u>(?<body>[^\\n]+?)</u>"), [.underlineStyle: NSUnderlineStyle.single.rawValue]),
(try! NSRegularExpression(pattern: "(?m)^(?:>[ \\t]*)+(?<body>[^\\n]+)"), [:]),
]
private static let heading = try! NSRegularExpression(pattern: "(?m)^(#{1,6}) (?<body>.+)$")
private static let headingFonts = [11.0, 8, 6, 4, 2, 0].map {
NSFont.monospacedSystemFont(ofSize: NotesAppearance.font.pointSize + $0, weight: .semibold)
}
private static let highlighter: Highlighter = {
guard let value = Highlighter() else { preconditionFailure("Missing bundled syntax highlighter") }
value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: NotesAppearance.font.pointSize)
return value
}()
private static let openingFence = try! NSRegularExpression(pattern: "(?m)^ {0,3}(`{3,}|~{3,})([^\\r\\n]*)\\r?\\n")
static func codeBlocks(in text: String) -> [(range: NSRange, body: NSRange, language: String?)] {
let source = text as NSString
var blocks: [(NSRange, NSRange, String?)] = []
var cursor = 0
while let opening = openingFence.firstMatch(in: text, range: NSRange(location: cursor, length: source.length - cursor)) {
let fence = source.substring(with: opening.range(at: 1))
let marker = NSRegularExpression.escapedPattern(for: String(fence.first!))
let closing = try! NSRegularExpression(pattern: "(?m)^ {0,3}\(marker){\(fence.count),}[ \\t]*\\r?$")
let start = NSMaxRange(opening.range)
let end = closing.firstMatch(in: text, range: NSRange(location: start, length: source.length - start))
let bodyEnd = end?.range.location ?? source.length
cursor = end.map { NSMaxRange($0.range) } ?? source.length
let language = source.substring(with: opening.range(at: 2)).split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
blocks.append((NSRange(location: opening.range.location, length: cursor - opening.range.location),
NSRange(location: start, length: bodyEnd - start), language))
if cursor == source.length { break }
}
return blocks
}
static func removeCodeBackground(from contents: NSMutableAttributedString) {
let range = NSRange(location: 0, length: contents.length)
contents.enumerateAttribute(codeBackground, in: range) { value, run, _ in
if value as? Bool == true { contents.removeAttribute(.backgroundColor, range: run) }
}
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 }
let text = editor.string
let range = NSRange(location: 0, length: text.utf16.count)
storage.beginEditing()
defer { storage.endEditing() }
storage.enumerateAttribute(.attachment, in: range) { value, _, _ in
guard let attachment = value as? NSTextAttachment,
let cell = attachment.attachmentCell as? NSTextAttachmentCell,
let image = cell.image else { return }
let size = image.size
let scale = min(1, 460 / max(size.width, 1), 240 / max(size.height, 1))
if scale < 1 { image.size = NSSize(width: size.width * scale, height: size.height * scale) }
if !(cell is RoundedImageAttachmentCell) {
attachment.attachmentCell = RoundedImageAttachmentCell(imageCell: image)
}
}
storage.removeAttribute(.strikethroughStyle, range: range)
storage.removeAttribute(.underlineStyle, range: range)
removeCodeBackground(from: storage)
storage.addAttribute(codeBackground, value: false, range: range)
storage.addAttribute(hiddenMarkup, value: false, range: range)
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text,
.paragraphStyle: NotesAppearance.paragraph], range: range)
let blocks = codeBlocks(in: text)
func hideSyntax(_ match: NSTextCheckingResult) {
let body = match.range(withName: "body")
guard body.location != NSNotFound else { return }
if body.location > match.range.location {
storage.addAttribute(hiddenMarkup, value: true,
range: NSRange(location: match.range.location, length: body.location - match.range.location))
}
if NSMaxRange(body) < NSMaxRange(match.range) {
storage.addAttribute(hiddenMarkup, value: true,
range: NSRange(location: NSMaxRange(body), length: NSMaxRange(match.range) - NSMaxRange(body)))
}
}
let codeSpans = inlineCode.matches(in: text, range: range).filter { match in
!blocks.contains { NSIntersectionRange($0.range, match.range).length > 0 }
}
for match in codeSpans {
let body = match.range(withName: "body")
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: SunsetPalette.accent,
.backgroundColor: SunsetPalette.selectionFill, codeBackground: true], range: body)
hideSyntax(match)
}
for (expression, attributes) in rules {
for match in expression.matches(in: text, range: range) {
guard !blocks.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }),
!codeSpans.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
storage.addAttributes(attributes, range: match.range)
if match.range(withName: "marker").length == 3,
let font = attributes[.font] as? NSFont {
storage.addAttribute(.font, value: NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask),
range: match.range)
}
hideSyntax(match)
}
}
for match in heading.matches(in: text, range: range) {
guard !blocks.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
hideSyntax(match)
let font = headingFonts[match.range(at: 1).length - 1]
storage.enumerateAttribute(.font, in: match.range) { value, run, _ in
let traits = (value as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? []
storage.addAttribute(.font, value: NSFontManager.shared.convert(font, toHaveTrait: traits), range: run)
}
}
for block in blocks {
storage.addAttributes([.font: NotesAppearance.font,
.foregroundColor: SunsetPalette.secondaryText], range: block.range)
storage.addAttribute(.foregroundColor, value: SunsetPalette.text, range: block.body)
let code = (text as NSString).substring(with: block.body)
guard let highlighted = highlighter.highlight(code, as: block.language),
highlighted.string == code else { continue }
highlighted.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: highlighted.length)) { color, range, _ in
if let color {
storage.addAttribute(.foregroundColor, value: color,
range: NSRange(location: block.body.location + range.location, length: range.length))
}
}
}
editor.typingAttributes = [.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text,
.paragraphStyle: NotesAppearance.paragraph]
}
}
File diff suppressed because it is too large Load Diff
+41 -42
View File
@@ -17,7 +17,7 @@ struct NotesVimState {
var linewiseRegister = false
}
// ponytail: project the whole note for each command; cache visible offsets if large notes make motions slow.
// ponytail: scan the whole note for each command; cache character offsets if large notes make motions slow.
@MainActor
private struct VimBuffer {
var characters: [Character] = []
@@ -29,12 +29,8 @@ private struct VimBuffer {
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))
}
characters.append(character)
ranges.append(NSRange(location: offset, length: size))
offset += size
}
}
@@ -98,10 +94,12 @@ extension NotesTextView {
var vimMode: NotesVimMode { vimState.mode }
func resetVim() {
endInsertionSession()
let register = vimState.register, linewise = vimState.linewiseRegister
vimState = NotesVimState()
vimState.register = register
vimState.linewiseRegister = linewise
needsDisplay = true
vimModeChanged?()
}
@@ -113,19 +111,22 @@ extension NotesTextView {
}
private func setVimMode(_ mode: NotesVimMode) {
if vimState.mode == .insert { endInsertionSession() }
breakUndoCoalescing()
clearVimCommand()
vimState.mode = mode
vimState.anchor = nil
vimState.head = nil
vimState.column = nil
if mode == .insert { beginInsertionSession() }
needsDisplay = true
vimModeChanged?()
}
@discardableResult
func handleVimEscape() -> Bool {
guard vimEnabled, vimState.mode != .normal, !hasMarkedText() else { return false }
let buffer = VimBuffer(attributedString())
let buffer = VimBuffer(documentContents)
var head = vimState.head ?? buffer.index(selectedRange().location)
if vimState.mode == .insert && head > buffer.lineStart(head) { head -= 1 }
setVimMode(.normal)
@@ -192,11 +193,12 @@ extension NotesTextView {
vimState.count = min(9999, vimState.count * 10 + digit)
return true
}
let buffer = VimBuffer(attributedString())
let buffer = VimBuffer(documentContents)
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)
needsDisplay = true
vimModeChanged?()
}
let origin = buffer.normal(vimState.head ?? buffer.index(selectedRange().location))
@@ -213,7 +215,7 @@ extension NotesTextView {
if key == "g" { vimState.awaitingG = true; return true }
if "dcy".contains(key) {
if visual {
let range = vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine)
let range = selectedRange()
applyVimOperator(key, range: range, linewise: vimState.mode == .visualLine)
} else if vimState.pending == key {
var end = buffer.lineEnd(origin)
@@ -305,22 +307,22 @@ extension NotesTextView {
setVimCaret(target, buffer: buffer)
insertNewline(nil)
if key == "O" {
let updated = VimBuffer(attributedString())
let updated = VimBuffer(documentContents)
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) }
if visual { applyVimOperator("d", range: selectedRange(), 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) }
if end > origin { applyVimOperator("d", range: 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)
applyVimOperator(key == "D" ? "d" : "c", range: 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)
@@ -348,7 +350,7 @@ extension NotesTextView {
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) }
if end > start { applyVimOperator(operation, range: NSRange(location: start, length: end - start), linewise: false) }
else { clearVimCommand() }
}
} else {
@@ -358,24 +360,21 @@ extension NotesTextView {
}
}
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)))
let first = buffer.offset(buffer.lineStart(start))
let lastLineEnd = buffer.lineEnd(end)
let last = buffer.offset(lastLineEnd + (buffer.newline(lastLineEnd) ? 1 : 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 source = documentContents
let register = NSMutableAttributedString(attributedString: source.attributedSubstring(from: range))
if linewise && !register.string.hasSuffix("\n") { register.append(NSAttributedString(string: "\n")) }
if linewise && !register.string.hasSuffix("\n") {
var attributes = register.length > 0 ? register.attributes(at: register.length - 1, effectiveRange: nil) : [:]
attributes[.attachment] = nil
register.append(NSAttributedString(string: "\n", attributes: attributes))
}
vimState.register = register
vimState.linewiseRegister = linewise
let originalCaret = selectedRange().location
@@ -386,12 +385,14 @@ extension NotesTextView {
return
}
var deletion = range
var replacement = ""
var replacement = NSAttributedString(string: "")
var attributes = range.location < source.length ? source.attributes(at: range.location, effectiveRange: nil) : typingAttributes
attributes[.attachment] = nil
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" }
if NSMaxRange(range) < text.length { replacement = NSAttributedString(string: "\n", attributes: attributes) }
} else if NSMaxRange(range) == text.length, range.location > 0 {
let previous = text.rangeOfComposedCharacterSequence(at: range.location - 1)
if text.substring(with: previous).allSatisfy(\.isNewline) {
@@ -400,10 +401,9 @@ extension NotesTextView {
}
}
}
replaceMarkdown(replacement, range: deletion)
if operation == "c" {
setSelectedRange(renderedRange(for: NSRange(location: deletion.location, length: 0)))
} else { normalizeVimCaret() }
replaceDocument(replacement, range: deletion, selection: NSRange(location: deletion.location, length: 0))
if operation == "c" { typingAttributes = attributes }
else { normalizeVimCaret() }
}
private func pasteVimRegister(before: Bool, count: Int, origin: Int, buffer: VimBuffer) {
@@ -412,11 +412,10 @@ extension NotesTextView {
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)
range = selectedRange()
} 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 source = documentContents.string as NSString
let line = source.lineRange(for: NSRange(location: buffer.offset(origin), 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) {
@@ -427,20 +426,20 @@ extension NotesTextView {
}
} else {
let index = before ? origin : min(buffer.lineEnd(origin), origin + 1)
range = markdownInsertionRange(for: NSRange(location: buffer.offset(index), length: 0))
range = NSRange(location: buffer.offset(index), length: 0)
}
setVimMode(.normal)
replaceMarkdown(insertion, range: range)
replaceDocument(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)))
setSelectedRange(NSRange(location: caret, length: 0))
if !vimState.linewiseRegister {
let updated = VimBuffer(attributedString())
let updated = VimBuffer(documentContents)
setVimCaret(updated.normal(max(0, updated.index(selectedRange().location) - 1)), buffer: updated)
} else { normalizeVimCaret() }
}
private func normalizeVimCaret() {
let buffer = VimBuffer(attributedString())
let buffer = VimBuffer(documentContents)
setVimCaret(buffer.normal(buffer.index(selectedRange().location)), buffer: buffer)
vimState.anchor = nil
vimState.head = nil