487 lines
26 KiB
Swift
487 lines
26 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: 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] = []
|
|
|
|
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)
|
|
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)
|
|
}
|
|
|
|
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? 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()
|
|
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: 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)) }
|
|
}
|
|
|
|
// Null layout glyphs must still be individual editable source positions.
|
|
private func moveAcrossListPrefix(forward: Bool, selecting: Bool) -> Bool {
|
|
let selection = selectedRange()
|
|
guard selection.length == 0 else { return false }
|
|
let source = string as NSString
|
|
let index = forward ? selection.location : selection.location - 1
|
|
guard index >= 0, index < source.length else { return false }
|
|
let attributes = textStorage?.attributes(at: index, effectiveRange: nil) ?? [:]
|
|
guard attributes[.attachment] is ListAttachment || attributes[listWhitespace] as? Bool == true else { return false }
|
|
let character = source.rangeOfComposedCharacterSequence(at: index)
|
|
setSelectedRange(selecting ? character
|
|
: NSRange(location: forward ? NSMaxRange(character) : character.location, length: 0))
|
|
return true
|
|
}
|
|
|
|
override func moveRight(_ sender: Any?) {
|
|
if !moveAcrossListPrefix(forward: true, selecting: false) { super.moveRight(sender) }
|
|
}
|
|
|
|
override func moveLeft(_ sender: Any?) {
|
|
if !moveAcrossListPrefix(forward: false, selecting: false) { super.moveLeft(sender) }
|
|
}
|
|
|
|
override func moveRightAndModifySelection(_ sender: Any?) {
|
|
if !moveAcrossListPrefix(forward: true, selecting: true) { super.moveRightAndModifySelection(sender) }
|
|
}
|
|
|
|
override func moveLeftAndModifySelection(_ sender: Any?) {
|
|
if !moveAcrossListPrefix(forward: false, selecting: true) { super.moveLeftAndModifySelection(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: 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? 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<CGGlyph>,
|
|
properties props: UnsafePointer<NSLayoutManager.GlyphProperty>,
|
|
characterIndexes charIndexes: UnsafePointer<Int>, font aFont: NSFont,
|
|
forGlyphRange glyphRange: NSRange) -> Int {
|
|
guard let storage = layoutManager.textStorage,
|
|
(0..<glyphRange.length).contains(where: {
|
|
storage.attribute(listWhitespace, at: charIndexes[$0], effectiveRange: nil) as? Bool == true
|
|
}) else { return 0 }
|
|
var properties = Array(UnsafeBufferPointer(start: props, count: glyphRange.length))
|
|
for index in properties.indices {
|
|
if storage.attribute(listWhitespace, at: charIndexes[index], effectiveRange: nil) as? Bool == true {
|
|
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.maxX - 18, 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)
|
|
}
|
|
}
|