import AppKit private let literalTaskMarker = NSAttributedString.Key("Conjure.literalTaskMarker") private let sourceTaskAttachment = NSAttributedString.Key("Conjure.sourceTaskAttachment") private let sourceListParagraph = NSAttributedString.Key("Conjure.sourceListParagraph") private let listWhitespace = NSAttributedString.Key("Conjure.listWhitespace") private final class ListAttachment: NSTextAttachment { let source: NSAttributedString let isTask: Bool var marker: String { source.string } var checked: Bool { marker.hasSuffix("[x]") || marker.hasSuffix("[X]") } @MainActor init(source: NSAttributedString, isTask: Bool) { self.source = source self.isTask = isTask super.init(data: nil, ofType: nil) let cell = ListAttachmentCell(textCell: isTask ? "" : (source.length == 1 ? "•" : source.string)) cell.attributedStringValue = NSAttributedString(string: cell.stringValue, attributes: [.font: NSFont.systemFont(ofSize: 14), .foregroundColor: SunsetPalette.accent]) cell.alignment = .right attachmentCell = cell bounds = NSRect(x: 0, y: -3, width: 18, height: 17) } required init?(coder: NSCoder) { fatalError("List attachments are serialized as Markdown") } } private final class ListAttachmentCell: NSTextAttachmentCell { nonisolated(unsafe) var columnWidth: CGFloat = 18 nonisolated override func cellSize() -> NSSize { NSSize(width: columnWidth, height: 17) } nonisolated override func cellBaselineOffset() -> NSPoint { NSPoint(x: 0, y: -3) } override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) { guard !stringValue.isEmpty else { return } let label = attributedStringValue let size = label.size() label.draw(at: NSPoint(x: cellFrame.maxX - 4 - size.width, y: cellFrame.midY - size.height / 2)) } } @MainActor final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate { private static let listPattern = try! NSRegularExpression( pattern: "(?m)^([ \\t]*)([-+*]|[0-9]+[.)])([ \\t]+)(?:(\\[([ xX]?)\\])(?=[ \\t]))?" ) private var tasks: [(range: NSRange, attachment: ListAttachment)] = [] private var checkboxes: [NSButton] = [] private var horizontalSelection: (anchor: Int, head: Int)? var markdownContents: NSAttributedString { Self.serialize(attributedString()) } var markdownString: String { markdownContents.string } func setMarkdownContents(_ contents: NSAttributedString) { horizontalSelection = nil textStorage?.setAttributedString(Self.render(contents)) refreshTasks() } private static func serialize(_ contents: NSAttributedString, keepingMetadata: Bool = false) -> NSAttributedString { let result = NSMutableAttributedString(attributedString: contents) restoreListLayout(in: result) var replacements: [(NSRange, ListAttachment)] = [] contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: contents.length)) { value, range, _ in if let task = value as? ListAttachment { replacements.append((range, task)) } } for (range, task) in replacements.reversed() { let marker = NSMutableAttributedString(attributedString: task.source) let markerRange = NSRange(location: 0, length: marker.length) marker.removeAttribute(literalTaskMarker, range: markerRange) if keepingMetadata { marker.addAttribute(sourceTaskAttachment, value: task, range: markerRange) } result.replaceCharacters(in: range, with: marker) } if !keepingMetadata { let range = NSRange(location: 0, length: result.length) result.removeAttribute(literalTaskMarker, range: range) result.removeAttribute(sourceTaskAttachment, range: range) } return result } private static func restoreListLayout(in contents: NSMutableAttributedString) { let range = NSRange(location: 0, length: contents.length) contents.enumerateAttribute(sourceListParagraph, in: range) { value, range, _ in guard let value else { return } if let style = value as? NSParagraphStyle { contents.addAttribute(.paragraphStyle, value: style, range: range) } else { contents.removeAttribute(.paragraphStyle, range: range) } } contents.removeAttribute(sourceListParagraph, range: range) contents.removeAttribute(listWhitespace, range: range) } // ponytail: parse the whole note on edits; incremental parsing if large 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) // Explicit values stop AppKit inheriting display-only attributes from replaced list text. result.addAttribute(listWhitespace, value: false, range: range) contents.enumerateAttribute(.paragraphStyle, in: range) { style, run, _ in result.addAttribute(sourceListParagraph, value: style ?? NSNull(), range: run) } 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 listPattern.matches(in: contents.string, range: range).reversed() { let isTask = match.range(at: 4).location != NSNotFound var markerRange = match.range(at: 2) if isTask { markerRange.length = NSMaxRange(match.range(at: 4)) - markerRange.location } let line = source.lineRange(for: match.range) if !isTask { let body = source.substring(with: NSRange(location: NSMaxRange(match.range), length: NSMaxRange(line) - NSMaxRange(match.range))) .trimmingCharacters(in: .newlines) // Leave a task's bracket prefix editable until its closing bracket has a separator. if ["[", "[ ", "[x", "[X"].contains(body) || ["[]", "[ ]", "[x]", "[X]"].contains(where: { body.hasPrefix($0) }) { continue } let marker = source.substring(with: markerRange) if marker == "-" || marker == "*" { let characters = source.substring(with: line).filter { !$0.isWhitespace } if characters.count >= 3, characters.allSatisfy({ String($0) == marker }) { continue } } } 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? ListAttachment let attachment: ListAttachment if let existing, existing.marker == marker { attachment = existing } else { let markerSource = NSMutableAttributedString(attributedString: contents.attributedSubstring(from: markerRange)) markerSource.removeAttribute(sourceTaskAttachment, range: NSRange(location: 0, length: markerSource.length)) attachment = ListAttachment(source: markerSource, isTask: isTask) } 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 attributes[listWhitespace] = false attributes[sourceListParagraph] = attributes[.paragraphStyle] ?? NSNull() 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) if let original = attributes.removeValue(forKey: sourceListParagraph) { attributes[.paragraphStyle] = original as? NSParagraphStyle } attributes.removeValue(forKey: listWhitespace) return attributes } func replaceMarkdown(_ text: String, range: NSRange) { replaceSource(NSAttributedString(string: text, attributes: textTypingAttributes), range: range, semantic: true) } func markdownInsertionRange(for rendered: NSRange) -> NSRange { guard rendered.length == 0 else { return markdownRange(for: rendered) } let body = listWhitespaceBoundary(from: rendered.location, forward: true) return markdownRange(for: NSRange(location: body, length: 0)) } 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: markdownInsertionRange(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? ListAttachment, let rhs = rhs as? ListAttachment { 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() let caret = renderedRange(for: NSRange(location: range.location + replacement.length, length: 0)) setSelectedRange(NSRange(location: listWhitespaceBoundary(from: caret.location, forward: true), length: 0)) } override func deleteBackward(_ sender: Any?) { let selection = selectedRange() let boundary = listWhitespaceBoundary(from: selection.location, forward: false) if selection.length == 0, let task = tasks.first(where: { NSMaxRange($0.range) == boundary }) { 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: ListAttachment), backwards: Bool) { let marker = task.attachment.source let remaining = NSRange(location: backwards ? 0 : 1, length: marker.length - 1) let text = NSMutableAttributedString(attributedString: marker.attributedSubstring(from: remaining)) text.addAttribute(literalTaskMarker, value: true, range: NSRange(location: 0, length: text.length)) let range = markdownRange(for: task.range) replaceSource(text, range: range, semantic: true) if !backwards { setSelectedRange(NSRange(location: task.range.location, length: 0)) } } // Only separator glyphs are null; indentation and body whitespace remain native stops. private func listWhitespaceBoundary(from position: Int, forward: Bool) -> Int { guard let storage = textStorage else { return position } let index = forward ? position : position - 1 guard index >= 0, index < storage.length else { return position } var range = NSRange() guard storage.attribute(listWhitespace, at: index, effectiveRange: &range) as? Bool == true else { return position } return forward ? NSMaxRange(range) : range.location } private func visualListBoundary(from position: Int, forward: Bool) -> Int? { let boundary = listWhitespaceBoundary(from: position, forward: forward) guard let task = tasks.first(where: { forward ? $0.range.location == boundary : NSMaxRange($0.range) == boundary }) else { return nil } return forward ? listWhitespaceBoundary(from: NSMaxRange(task.range), forward: true) : task.range.location } override func setSelectedRange(_ charRange: NSRange) { horizontalSelection = nil super.setSelectedRange(charRange) } override func setSelectedRange(_ charRange: NSRange, affinity: NSSelectionAffinity, stillSelecting: Bool) { horizontalSelection = nil super.setSelectedRange(charRange, affinity: affinity, stillSelecting: stillSelecting) } override func setSelectedRanges(_ ranges: [NSValue], affinity: NSSelectionAffinity, stillSelecting: Bool) { horizontalSelection = nil super.setSelectedRanges(ranges, affinity: affinity, stillSelecting: stillSelecting) } private func moveNatively(forward: Bool, selecting: Bool, sender: Any?) { if selecting { if forward { super.moveRightAndModifySelection(sender) } else { super.moveLeftAndModifySelection(sender) } } else { if forward { super.moveRight(sender) } else { super.moveLeft(sender) } } } private func moveHorizontally(forward: Bool, selecting: Bool, sender: Any?) { let selection = selectedRange() let tracked = selecting ? horizontalSelection : nil if !selecting { horizontalSelection = nil } let anchor: Int let head: Int var nativeHead: Int? if let tracked { anchor = tracked.anchor head = tracked.head } else if selecting, selection.length > 0 { // Let AppKit identify the active end of a mouse or otherwise native selection. moveNatively(forward: forward, selecting: true, sender: sender) let moved = selectedRange() guard moved != selection else { return } anchor = moved.location == selection.location ? selection.location : NSMaxRange(selection) head = anchor == selection.location ? NSMaxRange(selection) : selection.location nativeHead = moved.location == anchor ? NSMaxRange(moved) : moved.location } else { anchor = selection.location head = selection.location } if (selecting || selection.length == 0), let target = visualListBoundary(from: head, forward: forward) { if selecting { setHorizontalSelection(anchor: anchor, head: target) } else { setSelectedRange(NSRange(location: target, length: 0)) } return } // Native movement supplies composed-character, bidi, and line-boundary behavior. // Starting inside a null run already means "after the marker", so advance into the body. let origin = listWhitespaceBoundary(from: head, forward: true) if let nativeHead, origin == head { let target = listWhitespaceBoundary(from: nativeHead, forward: true) if target != nativeHead { setHorizontalSelection(anchor: anchor, head: target) } return } if tracked != nil || nativeHead != nil || (selection.length == 0 && origin != head) { setSelectedRange(NSRange(location: origin, length: 0)) } moveNatively(forward: forward, selecting: selecting, sender: sender) let moved = selectedRange() let newHead = selecting && moved.location == origin ? NSMaxRange(moved) : moved.location let target = listWhitespaceBoundary(from: newHead, forward: true) if selecting { if tracked != nil || nativeHead != nil || target != newHead || origin != head { setHorizontalSelection(anchor: anchor, head: target) } } else if target != newHead { setSelectedRange(NSRange(location: target, length: 0)) } } private func setHorizontalSelection(anchor: Int, head: Int) { setSelectedRange(NSRange(location: min(anchor, head), length: abs(head - anchor)), affinity: selectionAffinity, stillSelecting: false) horizontalSelection = (anchor, head) } override func moveRight(_ sender: Any?) { moveHorizontally(forward: true, selecting: false, sender: sender) } override func moveLeft(_ sender: Any?) { moveHorizontally(forward: false, selecting: false, sender: sender) } override func moveRightAndModifySelection(_ sender: Any?) { moveHorizontally(forward: true, selecting: true, sender: sender) } override func moveLeftAndModifySelection(_ sender: Any?) { moveHorizontally(forward: false, selecting: true, sender: sender) } 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: markdownInsertionRange(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() { horizontalSelection = nil 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? ListAttachment { tasks.append((range, attachment)) } } let taskIndices = tasks.indices.filter { tasks[$0].attachment.isTask } while checkboxes.count > taskIndices.count { let checkbox = checkboxes.removeLast() if window?.firstResponder === checkbox { window?.makeFirstResponder(self) } checkbox.removeFromSuperview() } while checkboxes.count < taskIndices.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, taskIndex) in taskIndices.enumerated() { let task = tasks[taskIndex] let checkbox = checkboxes[index] checkbox.tag = taskIndex 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 } applyListLayout() positionCheckboxes() } private func applyListLayout() { guard let storage = textStorage, let layoutManager else { return } let range = NSRange(location: 0, length: storage.length) layoutManager.delegate = self storage.beginEditing() Self.restoreListLayout(in: storage) let markerFont = NSFont.systemFont(ofSize: 14) let column = ceil(tasks.reduce(CGFloat(18)) { width, task in guard !task.attachment.isTask else { return width } return max(width, (task.attachment.marker as NSString).size(withAttributes: [.font: markerFont]).width + 4) }) let space = (" " as NSString).size(withAttributes: [.font: markerFont]).width let source = storage.string as NSString for task in tasks { let paragraph = source.paragraphRange(for: task.range) let indentation = source.substring(with: NSRange(location: paragraph.location, length: task.range.location - paragraph.location)) let depth = indentation.reduce(0) { $1 == "\t" ? $0 + 4 - $0 % 4 : $0 + 1 } let style = (storage.attribute(.paragraphStyle, at: task.range.location, effectiveRange: nil) as? NSParagraphStyle ?? NSParagraphStyle.default).mutableCopy() as! NSMutableParagraphStyle storage.enumerateAttribute(.paragraphStyle, in: paragraph) { original, run, _ in storage.addAttribute(sourceListParagraph, value: original ?? NSNull(), range: run) } // Keep indentation as native whitespace; null paragraph-leading glyphs lose first-line layout. style.firstLineHeadIndent = 0 style.headIndent = CGFloat(depth) * space + column style.tabStops = [] style.defaultTabInterval = 4 * space storage.addAttribute(.paragraphStyle, value: style, range: paragraph) var end = NSMaxRange(task.range) while end < NSMaxRange(paragraph) && (source.character(at: end) == 32 || source.character(at: end) == 9) { end += 1 } storage.addAttribute(listWhitespace, value: true, range: NSRange(location: NSMaxRange(task.range), length: end - NSMaxRange(task.range))) (task.attachment.attachmentCell as? ListAttachmentCell)?.columnWidth = column task.attachment.bounds.size.width = column } storage.endEditing() // Whitespace remains editable source, but occupies no part of the native marker column. layoutManager.invalidateGlyphs(forCharacterRange: range, changeInLength: 0, actualCharacterRange: nil) } func layoutManager(_ layoutManager: NSLayoutManager, shouldGenerateGlyphs glyphs: UnsafePointer, properties props: UnsafePointer, characterIndexes charIndexes: UnsafePointer, font aFont: NSFont, forGlyphRange glyphRange: NSRange) -> Int { guard let storage = layoutManager.textStorage, (0..