fix: render task checkboxes as atomic editor tokens
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user