Files
conjure/Sources/Conjure/NotesTextView.swift
T

639 lines
33 KiB
Swift

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: NotesAppearance.font, .foregroundColor: SunsetPalette.accent])
cell.alignment = source.length == 1 ? .center : .right
attachmentCell = cell
bounds = NSRect(x: 0, y: -4, width: 22, height: 21)
}
required init?(coder: NSCoder) { fatalError("List attachments are serialized as Markdown") }
}
private final class ListAttachmentCell: NSTextAttachmentCell {
nonisolated(unsafe) var columnWidth: CGFloat = 22
nonisolated override func cellSize() -> NSSize { NSSize(width: columnWidth, height: 21) }
nonisolated override func cellBaselineOffset() -> NSPoint { NSPoint(x: 0, y: -4) }
override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {
guard !stringValue.isEmpty else { return }
let label = attributedStringValue
let size = label.size()
let x = alignment == .center ? cellFrame.midX - size.width / 2 : cellFrame.maxX - 4 - size.width
label.draw(at: NSPoint(x: x,
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)
result.removeAttribute(MarkdownHighlighting.hiddenMarkup, range: NSRange(location: 0, length: result.length))
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)
attributes.removeValue(forKey: MarkdownHighlighting.hiddenMarkup)
return attributes
}
func replaceMarkdown(_ text: String, range: NSRange) {
replaceSource(NSAttributedString(string: text, attributes: textTypingAttributes), range: range, semantic: true)
}
func replaceMarkdown(_ contents: NSAttributedString, range: NSRange) {
replaceSource(contents, range: range, semantic: true)
}
func markdownInsertionRange(for rendered: NSRange) -> NSRange {
guard rendered.length == 0 else { return markdownRange(for: rendered) }
let body = hiddenBoundary(from: rendered.location, forward: true)
return markdownRange(for: NSRange(location: body, length: 0))
}
override func keyDown(with event: NSEvent) {
if event.keyCode == 48,
event.modifierFlags.intersection([.command, .control, .option, .shift]) == .shift,
delegate?.textView?(self, doCommandBy: #selector(NSResponder.insertBacktab(_:))) == true {
return
}
super.keyDown(with: event)
}
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,
typedInput: insertString is String)
}
private func replaceSource(_ replacement: NSAttributedString, range: NSRange, semantic: Bool, typedInput: Bool = false) {
guard isEditable else { return }
let source = NSMutableAttributedString(attributedString: Self.serialize(attributedString(), keepingMetadata: true))
guard range.location <= source.length, NSMaxRange(range) <= source.length else { return }
var existingWhitespace = false
if typedInput, range.length == 0, replacement.length > 0 {
var end = 0
(source.string as NSString).getLineStart(nil, end: nil, contentsEnd: &end,
for: NSRange(location: range.location, length: 0))
let tail = (source.string as NSString).substring(with: NSRange(location: range.location, length: max(0, end - range.location)))
existingWhitespace = !tail.isEmpty && tail.allSatisfy { $0 == " " || $0 == "\t" }
}
source.replaceCharacters(in: range, with: replacement)
if typedInput, range.length == 0, replacement.length > 0 {
let line = (source.string as NSString).lineRange(for: NSRange(location: range.location, length: 0))
if let match = Self.listPattern.firstMatch(in: source.string, range: line) {
var marker = match.range(at: 2)
if match.range(at: 4).location != NSNotFound {
marker.length = NSMaxRange(match.range(at: 4)) - marker.location
}
if range.location == NSMaxRange(marker),
replacement.string.allSatisfy({ $0 == " " || $0 == "\t" }) {
source.removeAttribute(literalTaskMarker, range: marker)
} else if existingWhitespace, range.location >= marker.location,
range.location + replacement.length == NSMaxRange(marker) {
source.addAttribute(literalTaskMarker, value: true, range: marker)
}
}
}
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: hiddenBoundary(from: caret.location, forward: true), length: 0))
}
override func deleteBackward(_ sender: Any?) {
let selection = selectedRange()
let boundary = hiddenBoundary(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 source = string as NSString
let atLineStart = boundary == 0 || source.character(at: boundary - 1) == 10
let range = boundary < selection.location && atLineStart
? NSRange(location: boundary, length: selection.location - boundary)
: source.rangeOfComposedCharacterSequence(at: max(0, boundary - 1))
replaceMarkdown("", range: markdownRange(for: range))
}
}
override func deleteForward(_ sender: Any?) {
let selection = selectedRange()
let boundary = hiddenBoundary(from: selection.location, forward: true)
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 boundary < string.utf16.count {
let range = (string as NSString).rangeOfComposedCharacterSequence(at: boundary)
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)) }
}
// Skip display-only syntax without changing the source offsets used by copy and save.
private func hiddenBoundary(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 }
func boundary(for key: NSAttributedString.Key) -> Int? {
var range = NSRange()
guard storage.attribute(key, at: index, effectiveRange: &range) as? Bool == true else { return nil }
return forward ? NSMaxRange(range) : range.location
}
return boundary(for: listWhitespace) ?? boundary(for: MarkdownHighlighting.hiddenMarkup) ?? position
}
private func visualListBoundary(from position: Int, forward: Bool) -> Int? {
let boundary = hiddenBoundary(from: position, forward: forward)
guard let task = tasks.first(where: {
forward ? $0.range.location == boundary : NSMaxRange($0.range) == boundary
}) else { return nil }
return forward ? hiddenBoundary(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 = hiddenBoundary(from: head, forward: true)
if let nativeHead, origin == head {
let target = hiddenBoundary(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 = hiddenBoundary(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 = NotesAppearance.font
let column = ceil(tasks.reduce(CGFloat(22)) { 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.lineSpacing = NotesAppearance.paragraph.lineSpacing
style.paragraphSpacing = NotesAppearance.paragraph.paragraphSpacing
style.headIndent = CGFloat(depth) * space + column
style.tabStops = []
style.defaultTabInterval = 4 * space
storage.addAttribute(.paragraphStyle, value: style, range: paragraph)
let separator = NSMaxRange(task.range)
if separator < NSMaxRange(paragraph), source.character(at: separator) == 32 || source.character(at: separator) == 9 {
storage.addAttribute(listWhitespace, value: true,
range: NSRange(location: separator, length: 1))
}
(task.attachment.attachmentCell as? ListAttachmentCell)?.columnWidth = column
task.attachment.bounds.size.width = column
}
storage.endEditing()
// Only the required separator is hidden; additional whitespace is visible item-body text.
layoutManager.invalidateGlyphs(forCharacterRange: range, changeInLength: 0, actualCharacterRange: nil)
}
func layoutManager(_ layoutManager: NSLayoutManager, shouldGenerateGlyphs glyphs: UnsafePointer<CGGlyph>,
properties props: UnsafePointer<NSLayoutManager.GlyphProperty>,
characterIndexes charIndexes: UnsafePointer<Int>, font aFont: NSFont,
forGlyphRange glyphRange: NSRange) -> Int {
guard let storage = layoutManager.textStorage else { return 0 }
func hidden(_ index: Int) -> Bool {
storage.attribute(listWhitespace, at: index, effectiveRange: nil) as? Bool == true
|| storage.attribute(MarkdownHighlighting.hiddenMarkup, at: index, effectiveRange: nil) as? Bool == true
}
guard (0..<glyphRange.length).contains(where: { hidden(charIndexes[$0]) }) else { return 0 }
var properties = Array(UnsafeBufferPointer(start: props, count: glyphRange.length))
for index in properties.indices {
if hidden(charIndexes[index]) {
properties[index] = .null
}
}
layoutManager.setGlyphs(glyphs, properties: &properties, characterIndexes: charIndexes,
font: aFont, forGlyphRange: glyphRange)
return glyphRange.length
}
override func layout() {
super.layout()
positionCheckboxes()
}
private func positionCheckboxes() {
guard !checkboxes.isEmpty, let layoutManager, let textContainer else { return }
layoutManager.ensureLayout(for: textContainer)
for checkbox in checkboxes {
let task = tasks[checkbox.tag]
let glyphs = layoutManager.glyphRange(forCharacterRange: task.range, actualCharacterRange: nil)
let rect = layoutManager.boundingRect(forGlyphRange: glyphs, in: textContainer)
.offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y)
checkbox.setFrameOrigin(NSPoint(x: rect.midX - checkbox.frame.width / 2,
y: rect.midY - checkbox.frame.height / 2))
}
}
@objc private func toggleTask(_ sender: NSButton) {
guard isEditable, tasks.indices.contains(sender.tag), tasks[sender.tag].attachment.isTask 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)
}
}