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 = [ "(?\\*{2,3}|_{2,3})(?\\S(?:[^\\n]*?\\S)?)\\k$", "(?[*_])(?[^\\s*_](?:[^*_\\n]*?[^\\s*_])?)\\k$", "(?\\S(?:[^\\n]*?\\S)?)~~$", "(?`{1,2})(?[^`\\n]+)\\k$", "(?(?[^\\n]+?)$", "(?[^\\]\\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) } }