fix: render task checkboxes as atomic editor tokens
This commit is contained in:
@@ -144,15 +144,15 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||
guard textView === editor, editor.isEditable,
|
||||
commandSelector == #selector(NSResponder.insertNewline(_:)) else { return false }
|
||||
let source = editor.string as NSString
|
||||
let selection = editor.selectedRange()
|
||||
let source = editor.markdownString as NSString
|
||||
let selection = editor.markdownRange(for: editor.selectedRange())
|
||||
var start = 0, end = 0
|
||||
source.getLineStart(&start, end: nil, contentsEnd: &end,
|
||||
for: NSRange(location: selection.location, length: 0))
|
||||
let line = source.substring(with: NSRange(location: start, length: end - start)) as NSString
|
||||
guard let item = Self.listItem.firstMatch(in: line as String, range: NSRange(location: 0, length: line.length)),
|
||||
selection.location >= start + item.range.length, NSMaxRange(selection) <= end,
|
||||
!MarkdownHighlighting.codeBlocks(in: editor.string).contains(where: {
|
||||
!MarkdownHighlighting.codeBlocks(in: source as String).contains(where: {
|
||||
selection.location >= $0.body.location && selection.location <= NSMaxRange($0.body)
|
||||
}) else { return false }
|
||||
let indent = line.substring(with: item.range(at: 1))
|
||||
@@ -183,10 +183,12 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
if Self.indentationWidth(String(leading)) < depth { break }
|
||||
}
|
||||
}
|
||||
editor.insertText(replacement, replacementRange: NSRange(location: start, length: end - start))
|
||||
editor.replaceMarkdown(replacement, range: NSRange(location: start, length: end - start))
|
||||
editor.setSelectedRange(editor.renderedRange(for: NSRange(location: start + replacement.utf16.count, length: 0)))
|
||||
} else {
|
||||
guard let prefix = Self.continuedPrefix(item, in: line) else { return false }
|
||||
editor.insertText("\n" + prefix, replacementRange: selection)
|
||||
editor.replaceMarkdown("\n" + prefix, range: selection)
|
||||
editor.setSelectedRange(editor.renderedRange(for: NSRange(location: selection.location + 1 + prefix.utf16.count, length: 0)))
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -195,7 +197,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
func save() -> Bool {
|
||||
guard isDirty, let store, let selectedID else { return true }
|
||||
do {
|
||||
try store.update(id: selectedID, contents: editor.attributedString())
|
||||
try store.update(id: selectedID, contents: editor.markdownContents)
|
||||
isDirty = false
|
||||
status.stringValue = "Saved locally · Markdown · Paste or drop images"
|
||||
status.textColor = SunsetPalette.secondaryText
|
||||
@@ -257,7 +259,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
guard let note = store?.notes.first(where: { $0.id == id }) else { return }
|
||||
selectedID = id
|
||||
defaults.set(id.uuidString, forKey: "selectedNote")
|
||||
editor.textStorage?.setAttributedString(note.contents)
|
||||
editor.setMarkdownContents(note.contents)
|
||||
editor.undoManager?.removeAllActions()
|
||||
editor.isEditable = true
|
||||
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
||||
@@ -299,87 +301,6 @@ private final class NotesView: NSView {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class NotesTextView: NSTextView {
|
||||
private struct Task {
|
||||
let marker: NSRange
|
||||
let state: NSRange
|
||||
let checked: Bool
|
||||
let title: String
|
||||
}
|
||||
private static let taskPattern = try! NSRegularExpression(
|
||||
pattern: "(?m)^[ \\t]*((?:[-+*]|[0-9]+[.)])[ \\t]+\\[([ xX])\\])(?=[ \\t]|\\r?$)"
|
||||
)
|
||||
private var tasks: [Task] = []
|
||||
private var checkboxes: [NSButton] = []
|
||||
|
||||
// ponytail: scan the whole note on edits; incremental parsing if large task lists make typing slow.
|
||||
func refreshTasks() {
|
||||
guard let layoutManager else { return }
|
||||
let source = string as NSString
|
||||
let range = NSRange(location: 0, length: source.length)
|
||||
layoutManager.removeTemporaryAttribute(.foregroundColor, forCharacterRange: range)
|
||||
let code = MarkdownHighlighting.codeBlocks(in: string)
|
||||
tasks = Self.taskPattern.matches(in: string, range: range).compactMap { match in
|
||||
guard !code.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { return nil }
|
||||
let end = NSMaxRange(source.lineRange(for: match.range))
|
||||
let title = source.substring(with: NSRange(location: NSMaxRange(match.range), length: end - NSMaxRange(match.range)))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Task(marker: match.range(at: 1), state: match.range(at: 2),
|
||||
checked: source.substring(with: match.range(at: 2)) != " ", title: title)
|
||||
}
|
||||
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)
|
||||
}
|
||||
for (index, task) in tasks.enumerated() {
|
||||
let checkbox = checkboxes[index]
|
||||
checkbox.tag = index
|
||||
checkbox.state = task.checked ? .on : .off
|
||||
checkbox.isEnabled = isEditable
|
||||
checkbox.setAccessibilityLabel(task.title.isEmpty ? "Task" : task.title)
|
||||
checkbox.toolTip = task.title
|
||||
layoutManager.addTemporaryAttribute(.foregroundColor, value: NSColor.clear, forCharacterRange: task.marker)
|
||||
}
|
||||
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.marker, 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()
|
||||
breakUndoCoalescing()
|
||||
insertText(task.checked ? " " : "x", replacementRange: task.state)
|
||||
breakUndoCoalescing()
|
||||
setSelectedRange(selection)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum MarkdownHighlighting {
|
||||
@@ -399,7 +320,7 @@ enum MarkdownHighlighting {
|
||||
}()
|
||||
private static let openingFence = try! NSRegularExpression(pattern: "(?m)^ {0,3}(`{3,}|~{3,})([^\\r\\n]*)\\r?\\n")
|
||||
|
||||
fileprivate static func codeBlocks(in text: String) -> [(range: NSRange, body: NSRange, language: String?)] {
|
||||
static func codeBlocks(in text: String) -> [(range: NSRange, body: NSRange, language: String?)] {
|
||||
let source = text as NSString
|
||||
var blocks: [(NSRange, NSRange, String?)] = []
|
||||
var cursor = 0
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
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("[ ]") }
|
||||
|
||||
@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]|\\r?$)"
|
||||
)
|
||||
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)
|
||||
replaceMarkdown(task.attachment.checked ? " " : "x",
|
||||
range: NSRange(location: range.location + marker.length - 2, length: 1))
|
||||
setSelectedRange(selection)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user