import AppKit private let literalTaskMarker = NSAttributedString.Key("Conjure.literalTaskMarker") private let sourceTaskAttachment = NSAttributedString.Key("Conjure.sourceTaskAttachment") private final class TaskAttachment: NSTextAttachment { let marker: String var checked: Bool { marker.hasSuffix("[x]") || marker.hasSuffix("[X]") } @MainActor init(marker: String) { self.marker = marker super.init(data: nil, ofType: nil) attachmentCell = TaskAttachmentCell(textCell: "") bounds = NSRect(x: 0, y: -3, width: 18, height: 17) } required init?(coder: NSCoder) { fatalError("Task attachments are serialized as Markdown") } } private final class TaskAttachmentCell: NSTextAttachmentCell { nonisolated override func cellSize() -> NSSize { NSSize(width: 18, height: 17) } nonisolated override func cellBaselineOffset() -> NSPoint { NSPoint(x: 0, y: -3) } override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {} } @MainActor final class NotesTextView: NSTextView { private static let taskPattern = try! NSRegularExpression( pattern: "(?m)^[ \\t]*((?:[-+*]|[0-9]+[.)])[ \\t]+\\[([ xX]?)\\])(?=[ \\t])" ) private var tasks: [(range: NSRange, attachment: TaskAttachment)] = [] private var checkboxes: [NSButton] = [] var markdownContents: NSAttributedString { Self.serialize(attributedString()) } var markdownString: String { markdownContents.string } func setMarkdownContents(_ contents: NSAttributedString) { textStorage?.setAttributedString(Self.render(contents)) refreshTasks() } private static func serialize(_ contents: NSAttributedString, keepingMetadata: Bool = false) -> NSAttributedString { let result = NSMutableAttributedString(attributedString: contents) var replacements: [(NSRange, TaskAttachment)] = [] contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: contents.length)) { value, range, _ in if let task = value as? TaskAttachment { replacements.append((range, task)) } } for (range, task) in replacements.reversed() { var attributes = contents.attributes(at: range.location, effectiveRange: nil) attributes.removeValue(forKey: .attachment) attributes.removeValue(forKey: literalTaskMarker) if keepingMetadata { attributes[sourceTaskAttachment] = task } result.replaceCharacters(in: range, with: NSAttributedString(string: task.marker, attributes: attributes)) } if !keepingMetadata { let range = NSRange(location: 0, length: result.length) result.removeAttribute(literalTaskMarker, range: range) result.removeAttribute(sourceTaskAttachment, range: range) } return result } // ponytail: parse the whole note on edits; incremental parsing if large task lists make typing slow. private static func render(_ contents: NSAttributedString) -> NSAttributedString { let source = contents.string as NSString let range = NSRange(location: 0, length: source.length) let code = MarkdownHighlighting.codeBlocks(in: contents.string) let result = NSMutableAttributedString(attributedString: contents) result.removeAttribute(sourceTaskAttachment, range: range) // AppKit inherits missing attributes from replaced text; false must explicitly clear literal remnants. result.addAttribute(literalTaskMarker, value: false, range: range) contents.enumerateAttribute(literalTaskMarker, in: range) { value, range, _ in if value as? Bool == true { result.addAttribute(literalTaskMarker, value: true, range: range) } } for match in taskPattern.matches(in: contents.string, range: range).reversed() { let markerRange = match.range(at: 1) guard !code.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue } var literal = false contents.enumerateAttribute(literalTaskMarker, in: markerRange) { value, _, stop in if value as? Bool == true { literal = true; stop.pointee = true } } guard !literal else { continue } let marker = source.substring(with: markerRange) let existing = contents.attribute(sourceTaskAttachment, at: markerRange.location, effectiveRange: nil) as? TaskAttachment let attachment = existing?.marker == marker ? existing! : TaskAttachment(marker: marker) let token = NSMutableAttributedString(attachment: attachment) var attributes = contents.attributes(at: markerRange.location, effectiveRange: nil) attributes.removeValue(forKey: .attachment) attributes.removeValue(forKey: sourceTaskAttachment) attributes[literalTaskMarker] = false token.addAttributes(attributes, range: NSRange(location: 0, length: 1)) result.replaceCharacters(in: markerRange, with: token) } return result } func markdownRange(for rendered: NSRange) -> NSRange { func offset(_ position: Int) -> Int { var result = min(position, string.utf16.count) for task in tasks where NSMaxRange(task.range) <= position { result += task.attachment.marker.utf16.count - 1 } return result } let start = offset(rendered.location) return NSRange(location: start, length: offset(NSMaxRange(rendered)) - start) } func renderedRange(for markdown: NSRange) -> NSRange { func offset(_ position: Int, end: Bool) -> Int { var expansion = 0 for task in tasks { let start = task.range.location + expansion let length = task.attachment.marker.utf16.count if position < start { break } if position < start + length { return task.range.location + ((position > start && end) ? 1 : 0) } expansion += length - 1 } return min(max(0, position - expansion), string.utf16.count) } let start = offset(markdown.location, end: markdown.length == 0) return NSRange(location: start, length: offset(NSMaxRange(markdown), end: true) - start) } private var textTypingAttributes: [NSAttributedString.Key: Any] { var attributes = typingAttributes attributes.removeValue(forKey: .attachment) attributes.removeValue(forKey: literalTaskMarker) attributes.removeValue(forKey: sourceTaskAttachment) return attributes } func replaceMarkdown(_ text: String, range: NSRange) { replaceSource(NSAttributedString(string: text, attributes: textTypingAttributes), range: range, semantic: true) } override func insertText(_ insertString: Any, replacementRange: NSRange) { let replacement: NSAttributedString if let attributed = insertString as? NSAttributedString { replacement = Self.serialize(attributed, keepingMetadata: true) } else if let text = insertString as? String { replacement = NSAttributedString(string: text, attributes: textTypingAttributes) } else { return } let range = replacementRange.location == NSNotFound ? selectedRange() : replacementRange replaceSource(replacement, range: markdownRange(for: range), semantic: false) } private func replaceSource(_ replacement: NSAttributedString, range: NSRange, semantic: Bool) { guard isEditable else { return } let source = NSMutableAttributedString(attributedString: Self.serialize(attributedString(), keepingMetadata: true)) guard range.location <= source.length, NSMaxRange(range) <= source.length else { return } source.replaceCharacters(in: range, with: replacement) let rendered = Self.render(source) let before = attributedString() let old = before.string as NSString, new = rendered.string as NSString func equal(_ left: Int, _ right: Int) -> Bool { guard old.character(at: left) == new.character(at: right) else { return false } let lhs = before.attribute(.attachment, at: left, effectiveRange: nil) as? NSTextAttachment let rhs = rendered.attribute(.attachment, at: right, effectiveRange: nil) as? NSTextAttachment if let lhs = lhs as? TaskAttachment, let rhs = rhs as? TaskAttachment { return lhs.marker == rhs.marker } guard lhs === rhs else { return false } return (before.attribute(literalTaskMarker, at: left, effectiveRange: nil) as? Bool == true) == (rendered.attribute(literalTaskMarker, at: right, effectiveRange: nil) as? Bool == true) } var prefix = 0 while prefix < min(old.length, new.length), equal(prefix, prefix) { prefix += 1 } if prefix < old.length { prefix = min(prefix, old.rangeOfComposedCharacterSequence(at: prefix).location) } if prefix < new.length { prefix = min(prefix, new.rangeOfComposedCharacterSequence(at: prefix).location) } var suffix = 0 while suffix < min(old.length, new.length) - prefix, equal(old.length - suffix - 1, new.length - suffix - 1) { suffix += 1 } for text in [old, new] where suffix > 0 && suffix < text.length { let boundary = text.length - suffix let character = text.rangeOfComposedCharacterSequence(at: boundary) if character.location < boundary { suffix = min(suffix, text.length - NSMaxRange(character)) } } let removed = NSRange(location: prefix, length: old.length - prefix - suffix) let inserted = NSRange(location: prefix, length: new.length - prefix - suffix) if removed.length > 0 || inserted.length > 0 { if semantic { breakUndoCoalescing() } super.insertText(rendered.attributedSubstring(from: inserted), replacementRange: removed) if semantic { breakUndoCoalescing() } } refreshTasks() setSelectedRange(renderedRange(for: NSRange(location: range.location + replacement.length, length: 0))) } override func deleteBackward(_ sender: Any?) { let selection = selectedRange() if selection.length == 0, let task = tasks.first(where: { NSMaxRange($0.range) == selection.location }) { expand(task, backwards: true) } else if selection.length > 0 { replaceMarkdown("", range: markdownRange(for: selection)) } else if selection.location > 0 { let range = (string as NSString).rangeOfComposedCharacterSequence(at: selection.location - 1) replaceMarkdown("", range: markdownRange(for: range)) } } override func deleteForward(_ sender: Any?) { let selection = selectedRange() if selection.length == 0, let task = tasks.first(where: { $0.range.location == selection.location }) { expand(task, backwards: false) } else if selection.length > 0 { replaceMarkdown("", range: markdownRange(for: selection)) } else if selection.location < string.utf16.count { let range = (string as NSString).rangeOfComposedCharacterSequence(at: selection.location) replaceMarkdown("", range: markdownRange(for: range)) } } private func expand(_ task: (range: NSRange, attachment: TaskAttachment), backwards: Bool) { let marker = task.attachment.marker as NSString let text = backwards ? marker.substring(to: marker.length - 1) : marker.substring(from: 1) var attributes = textTypingAttributes attributes[literalTaskMarker] = true let range = markdownRange(for: task.range) replaceSource(NSAttributedString(string: text, attributes: attributes), range: range, semantic: true) if !backwards { setSelectedRange(NSRange(location: task.range.location, length: 0)) } } private func markdownSelectionWriter() -> NSTextView { let writer = NSTextView() writer.isRichText = isRichText writer.importsGraphics = importsGraphics writer.textStorage?.setAttributedString(Self.serialize(attributedString().attributedSubstring(from: selectedRange()))) writer.setSelectedRange(NSRange(location: 0, length: writer.string.utf16.count)) return writer } override func writeSelection(to pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool { markdownSelectionWriter().writeSelection(to: pboard, type: type) } override func writeSelection(to pboard: NSPasteboard, types: [NSPasteboard.PasteboardType]) -> Bool { markdownSelectionWriter().writeSelection(to: pboard, types: types) } override func readSelection(from pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool { guard isEditable else { return false } // Let AppKit decode its supported rich text, file and image formats without touching this undo stack. let reader = NSTextView() reader.isRichText = isRichText reader.importsGraphics = importsGraphics guard reader.readSelection(from: pboard, type: type) else { return false } replaceSource(Self.serialize(reader.attributedString(), keepingMetadata: true), range: markdownRange(for: selectedRange()), semantic: true) return true } override func cut(_ sender: Any?) { guard isEditable, selectedRange().length > 0 else { return } copy(sender) replaceMarkdown("", range: markdownRange(for: selectedRange())) } override func didChangeText() { refreshTasks() super.didChangeText() } func refreshTasks() { tasks = [] let contents = attributedString() contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: contents.length)) { value, range, _ in if let attachment = value as? TaskAttachment { tasks.append((range, attachment)) } } while checkboxes.count > tasks.count { let checkbox = checkboxes.removeLast() if window?.firstResponder === checkbox { window?.makeFirstResponder(self) } checkbox.removeFromSuperview() } while checkboxes.count < tasks.count { let checkbox = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:))) checkbox.controlSize = .small checkbox.contentTintColor = SunsetPalette.accent checkbox.sizeToFit() checkboxes.append(checkbox) addSubview(checkbox) } let source = string as NSString for (index, task) in tasks.enumerated() { let checkbox = checkboxes[index] checkbox.tag = index checkbox.state = task.attachment.checked ? .on : .off checkbox.isEnabled = isEditable let end = NSMaxRange(source.lineRange(for: task.range)) let title = source.substring(with: NSRange(location: NSMaxRange(task.range), length: end - NSMaxRange(task.range))) .trimmingCharacters(in: .whitespacesAndNewlines) checkbox.setAccessibilityLabel(title.isEmpty ? "Task" : title) checkbox.toolTip = title } positionCheckboxes() } override func layout() { super.layout() positionCheckboxes() } private func positionCheckboxes() { guard let layoutManager, let textContainer else { return } layoutManager.ensureLayout(for: textContainer) for (index, task) in tasks.enumerated() { let glyphs = layoutManager.glyphRange(forCharacterRange: task.range, actualCharacterRange: nil) let rect = layoutManager.boundingRect(forGlyphRange: glyphs, in: textContainer) .offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y) let checkbox = checkboxes[index] checkbox.setFrameOrigin(NSPoint(x: rect.minX, y: rect.midY - checkbox.frame.height / 2)) } } @objc private func toggleTask(_ sender: NSButton) { guard isEditable, tasks.indices.contains(sender.tag) else { return } let task = tasks[sender.tag] let selection = selectedRange() let marker = task.attachment.marker as NSString let range = markdownRange(for: task.range) let opening = marker.range(of: "[", options: .backwards).location replaceMarkdown(task.attachment.checked ? " " : "x", range: NSRange(location: range.location + opening + 1, length: marker.length - opening - 2)) setSelectedRange(selection) } }