Files
conjure/Sources/Conjure/NotesTextView.swift
T

676 lines
37 KiB
Swift

import AppKit
import ConjureKit
import Highlighter
@MainActor
final class NotesTextView: NSTextView {
private let edits: UndoManager = {
let manager = UndoManager()
manager.groupsByEvent = false
return manager
}()
private var insertionSession = false
private var styling = false
private var emptyTypingAttributes: [NSAttributedString.Key: Any]?
private var operationDepth = 0
private var documentChanged = false
private var markerColumn: CGFloat = 22
private var checkboxes: [NSButton] = []
private var listParagraphs: [(range: NSRange, kind: String, depth: Int, number: Int, checked: Bool)] = []
private var themeObservation: ThemeObservation?
override init(frame frameRect: NSRect = .zero) {
super.init(frame: frameRect)
}
override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) {
super.init(frame: frameRect, textContainer: container)
themeObservation = ThemeObservation { [weak self] in self?.applyTheme() }
}
required init?(coder: NSCoder) { fatalError("Conjure creates note editors programmatically") }
/// Only visual attributes change here; semantic marks, selection and undo sessions stay intact.
private func applyTheme() {
insertionPointColor = ThemePalette.accent
selectedTextAttributes = [.backgroundColor: ThemePalette.selectionFill]
let typing = currentTypingAttributes
if let storage = textStorage {
let full = NSRange(location: 0, length: storage.length)
edits.disableUndoRegistration()
storage.beginEditing()
storage.enumerateAttributes(in: full) { attributes, range, _ in
let codeBlock = attributes[NoteAttributes.block] as? String == "code"
let inlineCode = attributes[NoteAttributes.inlineCode] as? Bool == true
storage.addAttribute(.foregroundColor,
value: inlineCode || attributes[.link] != nil ? ThemePalette.accent : ThemePalette.text,
range: range)
if codeBlock || inlineCode {
storage.addAttribute(.backgroundColor, value: ThemePalette.selectionFill, range: range)
}
}
applyCodeColors(to: storage)
storage.endEditing()
edits.enableUndoRegistration()
}
setTypingAttributes(typing)
for button in checkboxes { button.contentTintColor = ThemePalette.accent }
needsDisplay = true
}
private func applyCodeColors(to storage: NSTextStorage) {
let source = storage.string as NSString
storage.enumerateAttribute(NoteAttributes.block, in: NSRange(location: 0, length: storage.length)) { value, range, _ in
guard value as? String == "code" else { return }
let code = source.substring(with: range)
let language = storage.attribute(NoteAttributes.language, at: range.location, effectiveRange: nil) as? String
guard let highlighted = Self.highlighter.highlight(code, as: language), highlighted.string == code else { return }
highlighted.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: highlighted.length)) { value, run, _ in
if let value { storage.addAttribute(.foregroundColor, value: value,
range: NSRange(location: range.location + run.location, length: run.length)) }
}
}
}
var vimState = NotesVimState() {
didSet { if oldValue.mode != vimState.mode { needsDisplay = true } }
}
var vimModeChanged: (() -> Void)?
var vimEnabled = false {
didSet { endInsertionSession(); resetVim(); needsDisplay = true }
}
override var undoManager: UndoManager? { edits }
var documentContents: NSAttributedString { attributedString() }
func setDocumentContents(_ contents: NSAttributedString) {
endInsertionSession()
resetVim()
textStorage?.setAttributedString(contents)
applyDocumentStyle()
setSelectedRange(NSRange(location: 0, length: 0))
setTypingAttributes(attributes(at: 0))
}
func beginInsertionSession() {
guard !insertionSession else { return }
breakUndoCoalescing()
edits.beginUndoGrouping()
insertionSession = true
}
func endInsertionSession() {
guard insertionSession else { return }
breakUndoCoalescing()
insertionSession = false
edits.endUndoGrouping()
}
private func grouped(_ action: () -> Void) {
let ownsGroup = edits.groupingLevel == 0
if ownsGroup { edits.beginUndoGrouping() }
operationDepth += 1
action()
operationDepth -= 1
if operationDepth == 0, documentChanged {
documentChanged = false
let typing = currentTypingAttributes
applyDocumentStyle()
super.didChangeText()
setTypingAttributes(typing)
}
if ownsGroup { edits.endUndoGrouping() }
}
func replaceDocument(_ replacement: NSAttributedString, range: NSRange, selection: NSRange? = nil) {
guard isEditable, let storage = textStorage, range.location >= 0, range.location <= storage.length,
range.length >= 0, range.length <= storage.length - range.location else { return }
grouped {
// Preflight normally captures AppKit undo; this transaction owns the attributed inverse.
edits.disableUndoRegistration()
let allowed = shouldChangeText(in: range, replacementString: replacement.string)
edits.enableUndoRegistration()
guard allowed else { return }
let previous = storage.attributedSubstring(from: range)
let previousSelection = selectedRange()
let previousTyping = currentTypingAttributes
edits.registerUndo(withTarget: self) { editor in
editor.replaceDocument(previous,
range: NSRange(location: range.location, length: replacement.length), selection: previousSelection)
}
storage.replaceCharacters(in: range, with: replacement)
setSelectedRange(selection ?? NSRange(location: range.location + replacement.length, length: 0))
let typing = replacement.length > 0 ? replacement.attributes(at: replacement.length - 1, effectiveRange: nil)
: paragraph(at: selectedRange().location).length == 0 ? previousTyping : attributes(at: selectedRange().location)
setTypingAttributes(typing)
didChangeText()
}
}
override func keyDown(with event: NSEvent) {
if handleVimKey(event) { return }
if vimEnabled, vimMode == .insert, !event.modifierFlags.contains(.command) { beginInsertionSession() }
grouped { super.keyDown(with: event) }
}
override func resignFirstResponder() -> Bool {
let resigned = super.resignFirstResponder()
if resigned { endInsertionSession(); clearVimCommand() }
return resigned
}
override func mouseDown(with event: NSEvent) {
endInsertionSession()
if vimEnabled && vimMode != .insert { resetVim() }
else { clearVimCommand() }
super.mouseDown(with: event)
}
override func insertText(_ insertString: Any, replacementRange: NSRange) {
if vimEnabled, vimMode == .insert { beginInsertionSession() }
let range = replacementRange.location == NSNotFound ? selectedRange() : replacementRange
let replacement: NSAttributedString
if let value = insertString as? NSAttributedString { replacement = value }
else if let value = insertString as? String {
var typing = currentTypingAttributes
if range.location < string.utf16.count {
let context = attributes(at: range.location)
for key in Self.blockKeys {
typing.removeValue(forKey: key)
if let value = context[key] { typing[key] = value }
}
}
typing.removeValue(forKey: .attachment)
replacement = NSAttributedString(string: value, attributes: typing)
} else { return }
grouped {
replaceDocument(replacement, range: range)
if insertString is String, !hasMarkedText(), replacement.length > 0 { consumeShortcut() }
}
}
private func attributes(at position: Int) -> [NSAttributedString.Key: Any] {
guard let storage = textStorage, storage.length > 0 else {
return [.font: NotesAppearance.font, .foregroundColor: ThemePalette.text, .paragraphStyle: NotesAppearance.paragraph]
}
var result = storage.attributes(at: min(max(0, position), storage.length - 1), effectiveRange: nil)
result.removeValue(forKey: .attachment)
return result
}
private var currentTypingAttributes: [NSAttributedString.Key: Any] {
paragraph(at: selectedRange().location).length == 0 ? emptyTypingAttributes ?? typingAttributes : typingAttributes
}
private func setTypingAttributes(_ original: [NSAttributedString.Key: Any]) {
var attrs = original
attrs.removeValue(forKey: .attachment)
let level = min(6, max(1, attrs[NoteAttributes.headingLevel] as? Int ?? 1))
let heading = attrs[NoteAttributes.block] as? String == "heading"
let size: CGFloat = heading ? [26, 23, 21, 19, 17, 15][level - 1] : 15
let font = NSFont.monospacedSystemFont(ofSize: size,
weight: attrs[NoteAttributes.bold] as? Bool == true ? .bold : heading ? .semibold : .regular)
attrs[.font] = attrs[NoteAttributes.italic] as? Bool == true
? NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) : font
attrs[.foregroundColor] = attrs[NoteAttributes.inlineCode] as? Bool == true ? ThemePalette.accent : ThemePalette.text
typingAttributes = attrs
emptyTypingAttributes = paragraph(at: selectedRange().location).length == 0 ? attrs : nil
}
private static let blockKeys = [NoteAttributes.block, NoteAttributes.headingLevel, NoteAttributes.depth,
NoteAttributes.number, NoteAttributes.checked, NoteAttributes.language]
private static let inlineKeys = [NoteAttributes.bold, NoteAttributes.italic, NoteAttributes.inlineCode,
NSAttributedString.Key.underlineStyle, .strikethroughStyle, .link]
private func paragraph(at position: Int) -> NSRange {
(string as NSString).paragraphRange(for: NSRange(location: min(position, string.utf16.count), length: 0))
}
private func blockAttributes(_ kind: String?, from original: [NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any] {
var result = original
for key in Self.blockKeys { result.removeValue(forKey: key) }
if let kind { result[NoteAttributes.block] = kind }
return result
}
func format(_ tool: Int) {
guard isEditable else { return }
endInsertionSession()
let keys: [Int: NSAttributedString.Key] = [1: NoteAttributes.bold, 2: NoteAttributes.italic,
3: .strikethroughStyle, 4: NoteAttributes.inlineCode, 5: .link, 11: .underlineStyle]
if let key = keys[tool] {
let selection = selectedRange()
let existing = selection.length > 0 ? attributes(at: selection.location) : typingAttributes
let removing = (existing[key] as? Bool == true) || (existing[key] as? Int ?? 0) != 0 || (key == .link && existing[key] != nil)
let value: Any = key == .link ? "url" : (key == .underlineStyle || key == .strikethroughStyle ? 1 : true)
let booleanMark = [NoteAttributes.bold, NoteAttributes.italic, NoteAttributes.inlineCode].contains(key)
if selection.length == 0 {
if removing {
if booleanMark { typingAttributes[key] = false }
else { typingAttributes.removeValue(forKey: key) }
}
else { typingAttributes[key] = value }
return
}
let replacement = NSMutableAttributedString(attributedString: documentContents.attributedSubstring(from: selection))
let full = NSRange(location: 0, length: replacement.length)
if removing {
if booleanMark { replacement.addAttribute(key, value: false, range: full) }
else { replacement.removeAttribute(key, range: full) }
}
else { replacement.addAttribute(key, value: value, range: full) }
replaceDocument(replacement, range: selection, selection: selection)
} else {
let kinds = [0: "heading", 6: "code", 7: "quote", 8: "ordered", 9: "bullet", 10: "task"]
guard let kind = kinds[tool] else { return }
modifyParagraphs { attrs in
let old = attrs[NoteAttributes.block] as? String
if kind == "heading" {
let level = attrs[NoteAttributes.headingLevel] as? Int ?? 0
var changed = self.blockAttributes(level == 6 ? nil : kind, from: attrs)
if level < 6 { changed[NoteAttributes.headingLevel] = level + 1 }
return changed
}
var changed = self.blockAttributes(old == kind ? nil : kind, from: attrs)
if old != kind {
if ["bullet", "ordered", "task"].contains(kind) { changed[NoteAttributes.depth] = 0 }
if kind == "ordered" { changed[NoteAttributes.number] = 1 }
if kind == "task" { changed[NoteAttributes.checked] = false }
}
return changed
}
}
}
private func modifyParagraphs(_ transform: ([NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any]) {
let selection = selectedRange()
if string.isEmpty || selection.location == string.utf16.count && string.hasSuffix("\n") && selection.length == 0 {
setTypingAttributes(transform(currentTypingAttributes))
return
}
let length = selection.length > 0 ? selection.length - 1 : 0
let range = (string as NSString).paragraphRange(for: NSRange(location: selection.location, length: length))
let replacement = NSMutableAttributedString(attributedString: documentContents.attributedSubstring(from: range))
let source = replacement.string as NSString
var index = 0
while index < source.length {
let row = source.paragraphRange(for: NSRange(location: index, length: 0))
let attrs = transform(replacement.attributes(at: index, effectiveRange: nil))
for key in Self.blockKeys {
replacement.removeAttribute(key, range: row)
if let value = attrs[key] { replacement.addAttribute(key, value: value, range: row) }
}
index = NSMaxRange(row)
}
replaceDocument(replacement, range: range, selection: selection)
setTypingAttributes(attributes(at: selection.location))
}
override func insertTab(_ sender: Any?) {
if ["bullet", "ordered", "task"].contains(typingBlock) {
modifyParagraphs { attrs in
var attrs = attrs
attrs[NoteAttributes.depth] = (attrs[NoteAttributes.depth] as? Int ?? 0) + 1
if attrs[NoteAttributes.block] as? String == "ordered" { attrs[NoteAttributes.number] = 1 }
return attrs
}
} else { grouped { super.insertTab(sender) } }
}
override func insertBacktab(_ sender: Any?) {
guard ["bullet", "ordered", "task"].contains(typingBlock) else { return }
modifyParagraphs { attrs in
var attrs = attrs
attrs[NoteAttributes.depth] = max(0, (attrs[NoteAttributes.depth] as? Int ?? 0) - 1)
return attrs
}
}
private var typingBlock: String {
let attrs = selectedRange().location < string.utf16.count ? attributes(at: selectedRange().location) : currentTypingAttributes
return attrs[NoteAttributes.block] as? String ?? ""
}
override func insertNewline(_ sender: Any?) {
guard isEditable else { return }
grouped {
let selection = selectedRange()
let row = paragraph(at: selection.location)
let prefix = (string as NSString).substring(with: NSRange(location: row.location, length: selection.location - row.location))
if prefix.hasPrefix("```"), prefix.dropFirst(3).allSatisfy({ $0.isLetter || $0.isNumber || "_+.-".contains($0) }) {
var attrs = blockAttributes(typingBlock == "code" ? nil : "code", from: attributes(at: row.location))
if prefix.count > 3 { attrs[NoteAttributes.language] = String(prefix.dropFirst(3)) }
replaceDocument(NSAttributedString(string: "", attributes: attrs), range: NSRange(location: row.location, length: prefix.utf16.count))
setTypingAttributes(attrs)
return
}
let kind = typingBlock
let empty = (string as NSString).substring(with: row).trimmingCharacters(in: .newlines).isEmpty
if empty, ["bullet", "ordered", "task", "quote", "code"].contains(kind) {
let depth = currentTypingAttributes[NoteAttributes.depth] as? Int ?? 0
modifyParagraphs { attrs in
if depth > 0 {
var attrs = attrs; attrs[NoteAttributes.depth] = depth - 1; return attrs
}
return self.blockAttributes(nil, from: attrs)
}
return
}
var next = attributes(at: selection.location)
if selection.location == string.utf16.count { next = currentTypingAttributes }
if kind == "heading" { next = blockAttributes(nil, from: next) }
if kind == "task" { next[NoteAttributes.checked] = false }
if kind == "ordered" { next[NoteAttributes.number] = (next[NoteAttributes.number] as? Int ?? 1) + 1 }
for key in Self.inlineKeys { next.removeValue(forKey: key) }
if NSMaxRange(selection) <= NSMaxRange(row), !(kind == "heading" && selection.location == row.location) {
let tail = NSRange(location: NSMaxRange(selection), length: NSMaxRange(row) - NSMaxRange(selection))
let replacement = NSMutableAttributedString(string: "\n", attributes: next)
replacement.append(documentContents.attributedSubstring(from: tail))
let full = NSRange(location: 0, length: replacement.length)
for key in Self.blockKeys {
replacement.removeAttribute(key, range: full)
if let value = next[key] { replacement.addAttribute(key, value: value, range: full) }
}
replaceDocument(replacement, range: NSRange(location: selection.location, length: NSMaxRange(row) - selection.location),
selection: NSRange(location: selection.location + 1, length: 0))
} else {
replaceDocument(NSAttributedString(string: "\n", attributes: next), range: selection)
}
setTypingAttributes(next)
}
}
override func deleteBackward(_ sender: Any?) {
let selection = selectedRange()
if selection.length > 0 {
replaceDocument(NSAttributedString(string: ""), range: selection)
} else if selection.location > 0 {
let source = string as NSString
let removed = source.rangeOfComposedCharacterSequence(at: selection.location - 1)
if source.substring(with: removed).allSatisfy(\.isNewline) {
let current = paragraph(at: selection.location)
let previous = paragraph(at: removed.location)
let previousEmpty = source.substring(with: previous).trimmingCharacters(in: .newlines).isEmpty
let attrs = attributes(at: previousEmpty ? selection.location : previous.location)
let joined = NSUnionRange(previous, current)
let replacement = NSMutableAttributedString(attributedString: documentContents.attributedSubstring(from: joined))
replacement.deleteCharacters(in: NSRange(location: removed.location - joined.location, length: removed.length))
let all = NSRange(location: 0, length: replacement.length)
for key in Self.blockKeys {
replacement.removeAttribute(key, range: all)
if let value = attrs[key] { replacement.addAttribute(key, value: value, range: all) }
}
replaceDocument(replacement, range: joined, selection: NSRange(location: removed.location, length: 0))
setTypingAttributes(attrs)
} else {
replaceDocument(NSAttributedString(string: ""), range: removed)
}
}
}
override func deleteForward(_ sender: Any?) {
let selection = selectedRange()
if selection.length > 0 { replaceDocument(NSAttributedString(string: ""), range: selection) }
else if selection.location < string.utf16.count {
let range = (string as NSString).rangeOfComposedCharacterSequence(at: selection.location)
replaceDocument(NSAttributedString(string: ""), range: range)
}
}
override func cut(_ sender: Any?) {
guard isEditable, selectedRange().length > 0 else { return }
copy(sender)
replaceDocument(NSAttributedString(string: ""), range: selectedRange())
}
private static let richPasteboard = NSPasteboard.PasteboardType("dev.conjure.notes.document")
override var writablePasteboardTypes: [NSPasteboard.PasteboardType] { [Self.richPasteboard] + super.writablePasteboardTypes }
override var readablePasteboardTypes: [NSPasteboard.PasteboardType] { [Self.richPasteboard] + super.readablePasteboardTypes }
override func writeSelection(to pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool {
guard type == Self.richPasteboard else { return super.writeSelection(to: pboard, type: type) }
do {
let contents = documentContents.attributedSubstring(from: selectedRange())
guard let data = try NoteDocument.fileWrapper(contents).serializedRepresentation else { return false }
return pboard.setData(data, forType: type)
} catch { return false }
}
override func readSelection(from pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool {
guard isEditable else { return false }
let contents: NSAttributedString
if type == Self.richPasteboard {
guard let data = pboard.data(forType: type), let wrapper = FileWrapper(serializedRepresentation: data),
let decoded = try? NoteDocument.contents(from: wrapper) else { return false }
contents = decoded
} else {
let reader = NSTextView()
reader.isRichText = true; reader.importsGraphics = true
guard reader.readSelection(from: pboard, type: type) else { return false }
let imported = NSMutableAttributedString(attributedString: reader.attributedString())
imported.enumerateAttribute(.font, in: NSRange(location: 0, length: imported.length)) { value, range, _ in
guard let font = value as? NSFont else { return }
let traits = NSFontManager.shared.traits(of: font)
if traits.contains(.boldFontMask) { imported.addAttribute(NoteAttributes.bold, value: true, range: range) }
if traits.contains(.italicFontMask) { imported.addAttribute(NoteAttributes.italic, value: true, range: range) }
}
contents = imported
}
replaceDocument(contents, range: selectedRange())
return true
}
private static let inlineShortcuts = [
"(?<![\\\\*_])(?<marker>\\*{2,3}|_{2,3})(?<body>\\S(?:[^\\n]*?\\S)?)\\k<marker>$",
"(?<![\\\\*_])(?<marker>[*_])(?<body>[^\\s*_](?:[^*_\\n]*?[^\\s*_])?)\\k<marker>$",
"(?<!\\\\)~~(?<body>\\S(?:[^\\n]*?\\S)?)~~$",
"(?<![\\\\`])(?<marker>`{1,2})(?<body>[^`\\n]+)\\k<marker>$",
"(?<!\\\\)<u>(?<body>[^\\n]+?)</u>$",
"(?<!\\\\)\\[(?<body>[^\\]\\n]+)\\]\\([^\\n)]+\\)$"
].map { try! NSRegularExpression(pattern: $0) }
private func consumeShortcut() {
guard typingBlock != "code", selectedRange().length == 0 else { return }
let caret = selectedRange().location
guard caret > 0 else { return }
switch (string as NSString).character(at: caret - 1) {
case 32, 42, 95, 126, 96, 62, 41: break
default: return
}
let row = paragraph(at: caret)
let prefixRange = NSRange(location: row.location, length: caret - row.location)
let prefix = (string as NSString).substring(with: prefixRange)
var kind: String?, level = 0, number = 1
if prefix.hasSuffix(" ") {
let command = String(prefix.dropLast())
if (1...6).contains(command.count), command.allSatisfy({ $0 == "#" }) { kind = "heading"; level = command.count }
else if command == ">" { kind = "quote" }
else if ["-", "+", "*"].contains(command) { kind = "bullet" }
else if ["- [ ]", "- []", "- [x]", "- [X]"].contains(command) { kind = "task" }
else if ["[ ]", "[]", "[x]", "[X]"].contains(command), typingBlock == "bullet" { kind = "task" }
else if command.last == "." || command.last == ")", let value = Int(command.dropLast()), value > 0 { kind = "ordered"; number = value }
}
if let kind {
var attrs = blockAttributes(kind, from: attributes(at: row.location))
if kind == "heading" { attrs[NoteAttributes.headingLevel] = level }
if ["bullet", "ordered", "task"].contains(kind) { attrs[NoteAttributes.depth] = 0 }
if kind == "ordered" { attrs[NoteAttributes.number] = number }
if kind == "task" { attrs[NoteAttributes.checked] = prefix.lowercased().contains("[x]") }
let replacement = NSMutableAttributedString(attributedString: documentContents.attributedSubstring(from: row))
replacement.deleteCharacters(in: NSRange(location: 0, length: prefixRange.length))
for key in Self.blockKeys {
replacement.removeAttribute(key, range: NSRange(location: 0, length: replacement.length))
if let value = attrs[key] { replacement.addAttribute(key, value: value, range: NSRange(location: 0, length: replacement.length)) }
}
replaceDocument(replacement, range: row, selection: NSRange(location: row.location, length: 0))
setTypingAttributes(attrs)
return
}
// Inline shortcuts parse only the just-completed token, never the editing buffer.
for expression in Self.inlineShortcuts {
guard let match = expression.matches(in: prefix, range: NSRange(location: 0, length: prefix.utf16.count)).last else { continue }
let range = NSRange(location: row.location + match.range.location, length: match.range.length)
let token = documentContents.attributedSubstring(from: range)
let converted = NoteDocument.importMarkdown(token)
guard converted.string != token.string else { continue }
let next = currentTypingAttributes
replaceDocument(converted, range: range)
setTypingAttributes(next)
return
}
}
override func didChangeText() {
if operationDepth > 0 { documentChanged = true; return }
applyDocumentStyle()
super.didChangeText()
}
private static let highlighter: Highlighter = {
let value = Highlighter()!
value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: 15)
return value
}()
// ponytail: style all paragraphs; dirty-paragraph styling if large notes make editing slow.
func applyDocumentStyle() {
guard !styling, let storage = textStorage else { return }
styling = true
defer { styling = false }
let source = storage.string as NSString
let full = NSRange(location: 0, length: storage.length)
storage.beginEditing()
storage.removeAttribute(.backgroundColor, range: full)
var index = 0
listParagraphs = []
var counters: [Int: Int] = [:]
while index < storage.length {
let row = source.paragraphRange(for: NSRange(location: index, length: 0))
let attrs = storage.attributes(at: index, effectiveRange: nil)
let kind = attrs[NoteAttributes.block] as? String ?? ""
let depth = max(0, attrs[NoteAttributes.depth] as? Int ?? 0)
let level = min(6, max(1, attrs[NoteAttributes.headingLevel] as? Int ?? 1))
// A joined paragraph inherits its first block's type; inline marks remain independent.
for key in Self.blockKeys {
storage.removeAttribute(key, range: row)
if let value = attrs[key] { storage.addAttribute(key, value: value, range: row) }
}
let size: CGFloat = kind == "heading" ? [26, 23, 21, 19, 17, 15][level - 1] : 15
let paragraph = NotesAppearance.paragraph.mutableCopy() as! NSMutableParagraphStyle
if ["bullet", "ordered", "task"].contains(kind) {
let previous = counters[depth]
counters = counters.filter { $0.key <= depth }
let number = kind == "ordered" ? (previous.map { $0 + 1 } ?? (attrs[NoteAttributes.number] as? Int ?? 1)) : 1
if kind == "ordered" { counters[depth] = number; storage.addAttribute(NoteAttributes.number, value: number, range: row) }
else { counters.removeValue(forKey: depth) }
listParagraphs.append((row, kind, depth, number, attrs[NoteAttributes.checked] as? Bool == true))
paragraph.firstLineHeadIndent = CGFloat(depth) * 20 + 22
paragraph.headIndent = paragraph.firstLineHeadIndent
} else { counters = [:] }
if kind == "quote" { paragraph.firstLineHeadIndent = 18; paragraph.headIndent = 18 }
storage.addAttribute(.paragraphStyle, value: paragraph, range: row)
storage.enumerateAttributes(in: row) { run, range, _ in
let nativeTraits = (run[.font] as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? []
let bold = run[NoteAttributes.bold] as? Bool ?? (kind != "heading" && kind != "code" && nativeTraits.contains(.boldFontMask))
let italic = run[NoteAttributes.italic] as? Bool ?? nativeTraits.contains(.italicFontMask)
storage.addAttribute(NoteAttributes.bold, value: bold, range: range)
storage.addAttribute(NoteAttributes.italic, value: italic, range: range)
let font = NSFont.monospacedSystemFont(ofSize: size, weight: bold ? .bold : kind == "heading" ? .semibold : .regular)
storage.addAttribute(.font, value: italic ? NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) : font, range: range)
storage.addAttribute(.foregroundColor, value: run[NoteAttributes.inlineCode] as? Bool == true || run[.link] != nil ? ThemePalette.accent : ThemePalette.text, range: range)
if run[NoteAttributes.inlineCode] as? Bool == true || kind == "code" { storage.addAttribute(.backgroundColor, value: ThemePalette.selectionFill, range: range) }
}
index = NSMaxRange(row)
}
applyCodeColors(to: storage)
storage.enumerateAttribute(.attachment, in: full) { value, _, _ in
guard let attachment = value as? NSTextAttachment,
let cell = attachment.attachmentCell as? NSTextAttachmentCell, let image = cell.image else { return }
let scale = min(1, 460 / max(image.size.width, 1), 240 / max(image.size.height, 1))
if scale < 1 { image.size = NSSize(width: image.size.width * scale, height: image.size.height * scale) }
if !(cell is RoundedImageAttachmentCell) { attachment.attachmentCell = RoundedImageAttachmentCell(imageCell: image) }
}
markerColumn = ceil(listParagraphs.reduce(CGFloat(22)) { width, item in
guard item.kind == "ordered" else { return width }
return max(width, ("\(item.number)." as NSString).size(withAttributes: [.font: NotesAppearance.font]).width + 4)
})
for item in listParagraphs {
let paragraph = NotesAppearance.paragraph.mutableCopy() as! NSMutableParagraphStyle
paragraph.firstLineHeadIndent = CGFloat(item.depth) * 20 + markerColumn
paragraph.headIndent = paragraph.firstLineHeadIndent
storage.addAttribute(.paragraphStyle, value: paragraph, range: item.range)
}
storage.endEditing()
updateCheckboxes()
needsDisplay = true
}
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
guard let layoutManager else { return }
for item in listParagraphs where item.kind != "task" {
let glyph = layoutManager.glyphIndexForCharacter(at: item.range.location)
let line = layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil)
let baseline = line.minY + layoutManager.location(forGlyphAt: glyph).y + textContainerOrigin.y
let label = item.kind == "ordered" ? "\(item.number)." : "•"
let attrs: [NSAttributedString.Key: Any] = [.font: NotesAppearance.font, .foregroundColor: ThemePalette.accent]
let width = (label as NSString).size(withAttributes: attrs).width
let right = textContainerOrigin.x + CGFloat(item.depth) * 20 + markerColumn - 4
let x = item.kind == "ordered" ? right - width : right - (markerColumn - 4 + width) / 2
if dirtyRect.intersects(line.offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y)) {
(label as NSString).draw(at: NSPoint(x: x, y: baseline - NotesAppearance.font.ascender), withAttributes: attrs)
}
}
}
private func updateCheckboxes() {
let tasks = listParagraphs.filter { $0.kind == "task" }
while checkboxes.count > tasks.count { checkboxes.removeLast().removeFromSuperview() }
while checkboxes.count < tasks.count {
let button = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:)))
button.controlSize = .small; button.contentTintColor = ThemePalette.accent; button.sizeToFit()
checkboxes.append(button); addSubview(button)
}
for (button, task) in zip(checkboxes, tasks) {
button.tag = task.range.location; button.state = task.checked ? .on : .off; button.isEnabled = isEditable
let title = (string as NSString).substring(with: task.range).trimmingCharacters(in: .newlines)
button.setAccessibilityLabel(title.isEmpty ? "Task" : title)
}
positionCheckboxes()
}
private func positionCheckboxes() {
guard let layoutManager, let textContainer else { return }
layoutManager.ensureLayout(for: textContainer)
for button in checkboxes where button.tag < string.utf16.count {
let glyph = layoutManager.glyphIndexForCharacter(at: button.tag)
let line = layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil)
let baseline = line.minY + layoutManager.location(forGlyphAt: glyph).y + textContainerOrigin.y
let depth = attributes(at: button.tag)[NoteAttributes.depth] as? Int ?? 0
button.setFrameOrigin(NSPoint(x: textContainerOrigin.x + CGFloat(depth) * 20 + (markerColumn - 4 - button.frame.width) / 2,
y: baseline - NotesAppearance.font.capHeight / 2 - button.frame.height / 2))
}
}
override func layout() { super.layout(); positionCheckboxes() }
@objc private func toggleTask(_ sender: NSButton) {
guard isEditable, sender.tag < string.utf16.count else { return }
let range = paragraph(at: sender.tag), selection = selectedRange()
let replacement = NSMutableAttributedString(attributedString: documentContents.attributedSubstring(from: range))
replacement.addAttribute(NoteAttributes.checked, value: sender.state == .on, range: NSRange(location: 0, length: replacement.length))
replaceDocument(replacement, range: range, selection: selection)
window?.makeFirstResponder(self)
}
private var normalCursorWidth: CGFloat {
let font = attributes(at: selectedRange().location)[.font] as? NSFont ?? NotesAppearance.font
return max(2, ceil(font.maximumAdvancement.width))
}
override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) {
guard vimEnabled, vimMode == .normal else { super.drawInsertionPoint(in: rect, color: color, turnedOn: flag); return }
var block = rect; block.size.width = normalCursorWidth
super.drawInsertionPoint(in: block, color: color.withAlphaComponent(0.45), turnedOn: flag)
}
override func setNeedsDisplay(_ invalidRect: NSRect) {
var rect = invalidRect
if vimEnabled, vimMode == .normal { rect.size.width += normalCursorWidth }
super.setNeedsDisplay(rect)
}
}
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)
}
}