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
+219 -17
View File
@@ -95,7 +95,7 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws
reopenedWindow.contentViewController = reopened
defer { _ = reopened.close(); reopenedWindow.orderOut(nil) }
let reopenedViews = descendants(reopened.view)
let reopenedEditor = try #require(reopenedViews.compactMap { $0 as? NSTextView }.first)
let reopenedEditor = try #require(reopenedViews.compactMap { $0 as? NotesTextView }.first)
#expect(reopenedEditor.string == first.contents.string)
// Removing Markdown must also remove its previous visual style without changing the source.
reopenedEditor.insertText("Plain text", replacementRange: NSRange(location: 0, length: reopenedEditor.string.utf16.count))
@@ -105,7 +105,7 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws
defer { try? FileManager.default.removeItem(at: backup) }
try FileManager.default.moveItem(at: directory, to: backup)
try Data("Blocked storage".utf8).write(to: directory)
reopenedEditor.insertText("Unsaved", replacementRange: NSRange(location: 0, length: reopenedEditor.string.utf16.count))
reopenedEditor.insertText("- [X] Unsaved", replacementRange: NSRange(location: 0, length: reopenedEditor.string.utf16.count))
var closed = false
reopened.closed = { closed = true }
reopened.goBack()
@@ -114,7 +114,8 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws
reopened.browseNotes()
filter(try picker(reopened), "scnd")
#expect(reopened.handle(#selector(NSResponder.insertNewline(_:))))
#expect(reopenedEditor.string == "Unsaved")
#expect(reopenedEditor.string == "\u{fffc} Unsaved")
#expect(reopenedEditor.markdownString == "- [X] Unsaved")
#expect(try selectedID() == firstID)
reopened.browseNotes()
#expect(!reopened.close())
@@ -123,6 +124,7 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws
try FileManager.default.removeItem(at: directory)
try FileManager.default.moveItem(at: backup, to: directory)
#expect(reopened.save())
#expect(try NotesStore(directory: directory).notes.first { $0.id == firstID }?.contents.string == "- [X] Unsaved")
#expect(reopened.close())
#expect(closed)
#expect(!descendants(reopened.view).contains { ($0 as? NSSearchField)?.accessibilityLabel() == "Search notes" })
@@ -247,7 +249,7 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
window.contentViewController = controller
defer { _ = controller.close(); window.orderOut(nil) }
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let editor = try #require(descendants(controller.view).compactMap { $0 as? NSTextView }.first)
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
window.makeFirstResponder(editor)
let enter = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "\r",
@@ -264,7 +266,10 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
("9. ninth", "9. ninth\n10. "),
(" 12) nested", " 12) nested\n 13) "),
("- [x] finished", "- [x] finished\n- [ ] "),
("- ", ""), ("+ \t ", ""), ("4. ", ""), ("- [ ] ", ""),
(" \t+ [X]\tfinished", " \t+ [X]\tfinished\n \t+ [ ]\t"),
("1. parent\n - [X] ", "1. parent\n2. "),
("- [x] parent\n - [ ] ", "- [x] parent\n- [ ] "),
("- ", ""), ("+ \t ", ""), ("4. ", ""), ("- [ ] ", ""), ("- [X]", ""),
("- parent\n - ", "- parent\n- "),
("1. parent\n * ", "1. parent\n2. "),
("- parent\n\t+ child\n\t\t3) ", "- parent\n\t+ child\n\t+ "),
@@ -281,14 +286,21 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
for (before, after) in examples {
reset(before)
editor.keyDown(with: enter)
#expect(editor.string == after, "Return in \(before.debugDescription)")
#expect(editor.selectedRange() == NSRange(location: after.utf16.count, length: 0))
#expect(editor.markdownString == after, "Return in \(before.debugDescription)")
#expect(editor.markdownContents.string == after)
#expect(editor.selectedRange() == NSRange(location: editor.string.utf16.count, length: 0))
}
reset("- ab🦊cd")
editor.setSelectedRange((editor.string as NSString).range(of: "🦊"))
editor.keyDown(with: enter)
#expect(editor.string == "- ab\n- cd")
#expect(editor.selectedRange() == NSRange(location: 7, length: 0))
reset("- [X] ab🦊cd")
editor.setSelectedRange((editor.string as NSString).range(of: "🦊"))
editor.keyDown(with: enter)
#expect(editor.markdownString == "- [X] ab\n- [ ] cd")
#expect(editor.string == "\u{fffc} ab\n\u{fffc} cd")
#expect(editor.selectedRange() == NSRange(location: 7, length: 0))
reset("- parent\n - child\n - leaf")
editor.keyDown(with: enter)
#expect(editor.string == "- parent\n - child\n - leaf\n - ")
@@ -310,6 +322,17 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
}
editor.keyDown(with: enter)
#expect(editor.string == before + "\n")
reset("- [X] parent\n - [ ] ")
editor.keyDown(with: enter)
editor.breakUndoCoalescing()
#expect(editor.markdownString == "- [X] parent\n- [ ] ")
#expect(editor.string == "\u{fffc} parent\n\u{fffc} ")
editor.undoManager?.undo()
#expect(editor.markdownString == "- [X] parent\n - [ ] ")
#expect(editor.string == "\u{fffc} parent\n \u{fffc} ")
#expect(editor.undoManager?.canUndo == false)
editor.undoManager?.redo()
#expect(editor.markdownString == "- [X] parent\n- [ ] ")
reset("7. saved")
editor.keyDown(with: enter)
editor.breakUndoCoalescing()
@@ -341,10 +364,14 @@ func taskCheckboxesToggleMarkdownWithUndoPersistenceAndListContinuation() throws
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
window.makeFirstResponder(editor)
let before = "- [ ] buy 🦊\n - [X] nested\n```md\n- [ ] literal\n```\nprose - [ ] not a task"
let before = "- [ ] buy 🦊\n \t+ [X]\tnested\n```md\n- [ ] literal\n```\nprose - [ ] not a task"
let rendered = "\u{fffc} buy 🦊\n \t\u{fffc}\tnested\n```md\n- [ ] literal\n```\nprose - [ ] not a task"
editor.insertText(before, replacementRange: editor.selectedRange())
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
#expect(editor.string == rendered)
#expect(editor.markdownContents.string == before)
#expect(editor.textStorage?.attribute(.attachment, at: 0, effectiveRange: nil) is NSTextAttachment)
let boxes = editor.subviews.compactMap { $0 as? NSButton }
#expect(boxes.map(\.state) == [.off, .on])
#expect(boxes.first?.cell?.accessibilityRole() == .checkBox)
@@ -353,19 +380,22 @@ func taskCheckboxesToggleMarkdownWithUndoPersistenceAndListContinuation() throws
let first = try #require(boxes.first)
first.performClick(nil)
let checked = before.replacingOccurrences(of: "- [ ] buy", with: "- [x] buy")
#expect(editor.string == checked)
#expect(editor.string == rendered)
#expect(editor.markdownString == checked)
#expect(first.state == .on)
#expect(editor.selectedRange() == selection)
let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:)))
let saved = try #require(NotesStore(directory: directory).notes.first { $0.id == id })
#expect(saved.contents.string == checked)
#expect(saved.contents.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor != .clear)
#expect(saved.contents.attribute(.attachment, at: 0, effectiveRange: nil) == nil)
editor.undoManager?.undo()
#expect(editor.string == before)
#expect(first.state == .off)
#expect(editor.markdownString == before)
#expect(editor.string == rendered)
#expect(editor.subviews.compactMap { $0 as? NSButton }.first?.state == .off)
editor.undoManager?.redo()
#expect(editor.string == checked)
#expect(first.state == .on)
#expect(editor.markdownString == checked)
#expect(editor.string == rendered)
#expect(editor.subviews.compactMap { $0 as? NSButton }.first?.state == .on)
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
let board = NSPasteboard.withUniqueName()
defer { board.releaseGlobally() }
@@ -374,16 +404,188 @@ func taskCheckboxesToggleMarkdownWithUndoPersistenceAndListContinuation() throws
let reopened = NotesController(defaults: defaults, directory: directory)
defer { _ = reopened.close() }
let restoredEditor = try #require(descendants(reopened.view).compactMap { $0 as? NotesTextView }.first)
#expect(restoredEditor.string == checked)
#expect(restoredEditor.markdownString == checked)
#expect(restoredEditor.string == rendered)
#expect(restoredEditor.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.on, .on])
editor.insertText("- [x] finished", replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
let enter = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "\r",
charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36))
editor.keyDown(with: enter)
#expect(editor.string == "- [x] finished\n- [ ] ")
#expect(editor.markdownString == "- [x] finished\n- [ ] ")
#expect(editor.string == "\u{fffc} finished\n\u{fffc} ")
#expect(editor.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.on, .off])
editor.keyDown(with: enter)
#expect(editor.string == "- [x] finished\n")
#expect(editor.markdownString == "- [x] finished\n")
#expect(editor.string == "\u{fffc} finished\n")
#expect(editor.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.on])
}
@Test @MainActor
func atomicTasksExpandAtDeletionBoundariesAndNormalizeLineJoinsInOneUndo() throws {
_ = NSApplication.shared
let suite = "NotesTaskDeletion.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
defer {
defaults.removePersistentDomain(forName: suite)
try? FileManager.default.removeItem(at: directory)
}
let controller = NotesController(defaults: defaults, directory: directory)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 390),
styleMask: .borderless, backing: .buffered, defer: false)
window.contentViewController = controller
defer { _ = controller.close(); window.orderOut(nil) }
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
window.makeFirstResponder(editor)
func reset(_ source: String) -> String {
editor.insertText(source, replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
return editor.string
}
func delete(backward: Bool) {
if backward { editor.deleteBackward(nil) }
else { editor.deleteForward(nil) }
editor.breakUndoCoalescing()
}
let boundaries = [
("- [ ] buy", true, "- [ buy"),
("\t+ [X]\t🦊", true, "\t+ [X\t🦊"),
(" 12)\t[x] done", true, " 12)\t[x done"),
("- [ ] buy", false, " [ ] buy"),
("\t+ [X]\t🦊", false, "\t [X]\t🦊"),
(" 12)\t[x] done", false, " 2)\t[x] done"),
]
for (source, backward, expanded) in boundaries {
let rendered = reset(source)
let token = (rendered as NSString).range(of: "\u{fffc}")
#expect(token.length == 1)
editor.setSelectedRange(NSRange(location: token.location + (backward ? 1 : 0), length: 0))
delete(backward: backward)
#expect(editor.string == expanded)
#expect(editor.markdownString == expanded)
let caret = backward ? (source as NSString).range(of: "]").location : token.location
#expect(editor.selectedRange() == NSRange(location: caret, length: 0))
editor.undoManager?.undo()
#expect(editor.string == rendered)
#expect(editor.markdownString == source)
#expect(editor.undoManager?.canUndo == false)
editor.undoManager?.redo()
#expect(editor.string == expanded)
#expect(editor.markdownString == expanded)
}
let joins = [
("prose\n- [ ] task", "prose- [ ] task", "prose- [ ] task"),
("prose\n - [X] task", "prose - [X] task", "prose - [X] task"),
("🦊\n\t12) [x]\tfinish", "🦊\t12) [x]\tfinish", "🦊\t12) [x]\tfinish"),
("\n- [ ] task", "- [ ] task", "\u{fffc} task"),
(" \t\n + [X] task", " \t + [X] task", " \t \u{fffc} task"),
]
for (source, joined, renderedJoin) in joins {
for backward in [true, false] {
let rendered = reset(source)
let newline = (rendered as NSString).range(of: "\n")
editor.setSelectedRange(NSRange(location: newline.location + (backward ? 1 : 0), length: 0))
delete(backward: backward)
#expect(editor.markdownString == joined)
#expect(editor.string == renderedJoin)
editor.undoManager?.undo()
#expect(editor.markdownString == source)
#expect(editor.string == rendered)
#expect(editor.undoManager?.canUndo == false)
editor.undoManager?.redo()
#expect(editor.string == renderedJoin)
#expect(editor.markdownString == joined)
}
}
}
@Test @MainActor
func atomicTasksCopySelectAndPersistMarkdownAlongsideUnicodeAndImages() throws {
_ = NSApplication.shared
let suite = "NotesTaskImages.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
defer {
defaults.removePersistentDomain(forName: suite)
try? FileManager.default.removeItem(at: directory)
}
let bitmap = try #require(NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
bitmap.setColor(.red, atX: 0, y: 0)
let png = try #require(bitmap.representation(using: .png, properties: [:]))
let imageFile = FileWrapper(regularFileWithContents: png)
imageFile.preferredFilename = "pixel.png"
let source = NSMutableAttributedString(string: "\t12)\t[X]\t🦊 ")
let imageLocation = source.length
source.append(NSAttributedString(attachment: NSTextAttachment(fileWrapper: imageFile)))
source.append(NSAttributedString(string: "\n - [ ] tail"))
let store = try NotesStore(directory: directory)
let id = try store.create()
try store.update(id: id, contents: source)
defaults.set(id.uuidString, forKey: "selectedNote")
let controller = NotesController(defaults: defaults, directory: directory)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 390),
styleMask: .borderless, backing: .buffered, defer: false)
window.contentViewController = controller
defer { _ = controller.close(); window.orderOut(nil) }
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
window.makeFirstResponder(editor)
let rendered = "\t\u{fffc}\t🦊 \u{fffc}\n \u{fffc} tail"
#expect(editor.string == rendered)
#expect(editor.markdownString == source.string)
let token = NSRange(location: 1, length: 1)
let marker = (source.string as NSString).range(of: "12)\t[X]")
#expect(editor.markdownRange(for: token) == marker)
#expect(editor.renderedRange(for: (source.string as NSString).range(of: "[X]")) == token)
#expect(editor.renderedRange(for: (source.string as NSString).range(of: "🦊")) == (rendered as NSString).range(of: "🦊"))
editor.setSelectedRange(NSRange(location: token.location, length: 0))
editor.moveRight(nil)
#expect(editor.selectedRange() == NSRange(location: NSMaxRange(token), length: 0))
editor.moveLeft(nil)
#expect(editor.selectedRange() == NSRange(location: token.location, length: 0))
editor.moveRightAndModifySelection(nil)
#expect(editor.selectedRange() == token)
let board = NSPasteboard.withUniqueName()
defer { board.releaseGlobally() }
editor.setSelectedRange(token)
#expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes))
#expect(board.string(forType: .string) == "12)\t[X]")
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
board.clearContents()
#expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes))
#expect(board.string(forType: .string) == source.string)
let copiedData = try #require(board.data(forType: .rtfd))
let copied = try NSAttributedString(data: copiedData, options: [.documentType: NSAttributedString.DocumentType.rtfd], documentAttributes: nil)
#expect(copied.string == source.string)
#expect((copied.attribute(.attachment, at: imageLocation, effectiveRange: nil) as? NSTextAttachment)?.fileWrapper?.regularFileContents == png)
editor.undoManager?.removeAllActions()
editor.setSelectedRange(token)
editor.deleteBackward(nil)
editor.breakUndoCoalescing()
#expect(editor.string == "\t\t🦊 \u{fffc}\n \u{fffc} tail")
#expect(editor.markdownString == (source.string as NSString).replacingCharacters(in: marker, with: ""))
editor.undoManager?.undo()
#expect(editor.string == rendered)
#expect(editor.markdownString == source.string)
#expect(editor.undoManager?.canUndo == false)
#expect(controller.save())
let saved = try #require(NotesStore(directory: directory).notes.first { $0.id == id })
#expect(saved.contents.string == source.string)
var savedAttachments: [NSTextAttachment] = []
saved.contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: saved.contents.length)) { value, _, _ in
if let attachment = value as? NSTextAttachment { savedAttachments.append(attachment) }
}
#expect(savedAttachments.count == 1)
#expect(savedAttachments.first?.fileWrapper?.regularFileContents == png)
let reopened = NotesController(defaults: defaults, directory: directory)
defer { _ = reopened.close() }
let reopenedEditor = try #require(descendants(reopened.view).compactMap { $0 as? NotesTextView }.first)
#expect(reopenedEditor.string == rendered)
#expect(reopenedEditor.markdownContents.string == source.string)
}