fix: render task checkboxes as atomic editor tokens

This commit is contained in:
Valdemar Lorenzen
2026-10-19 12:01:18 +02:00
parent 655f4b4d98
commit 7c26a88c6c
4 changed files with 559 additions and 108 deletions
+10 -89
View File
@@ -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