fix: render task checkboxes as atomic editor tokens
This commit is contained in:
@@ -139,9 +139,9 @@ Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it
|
|||||||
|
|
||||||
Actions opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P) and **New Note** (Command-N). Command-N creates a note while the editor or either popup is focused. Command-P toggles Browse Notes even while its footer pill is covered. Escape, Back, or clicking outside closes an open popup and returns focus to the editor without leaving Notes; another Escape dismisses the launcher. Control-J/K navigates only while a popup is open, preserving normal editor commands when it is closed.
|
Actions opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P) and **New Note** (Command-N). Command-N creates a note while the editor or either popup is focused. Command-P toggles Browse Notes even while its footer pill is covered. Escape, Back, or clicking outside closes an open popup and returns focus to the editor without leaving Notes; another Escape dismisses the launcher. Control-J/K navigates only while a popup is open, preserving normal editor commands when it is closed.
|
||||||
|
|
||||||
Edit Markdown directly: headings, bold, italic, lists, links, inline code, and fenced code blocks are highlighted while the source remains visible. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and task lists at the same indentation, incrementing numbers and resetting task checkboxes. On an empty item, Return exits one nesting level; repeat to leave the list entirely. Nesting has no fixed depth limit. Prose and fenced code retain ordinary newlines. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note.
|
Edit Markdown directly: headings, bold, italic, lists, links, inline code, and fenced code blocks are highlighted while the source remains visible, except for task markers rendered as checkbox tokens. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and task lists at the same indentation, incrementing numbers and resetting task checkboxes. On an empty item, Return exits one nesting level; repeat to leave the list entirely. Nesting has no fixed depth limit. Prose and fenced code retain ordinary newlines. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note.
|
||||||
|
|
||||||
Task-list markers such as `- [ ]` and `- [x]` display as native checkboxes, including nested tasks. Click a checkbox to toggle completion; toggles autosave and support Undo/Redo without moving the text selection. The original Markdown remains in saved notes and copied text, rather than being replaced with image attachments. Task syntax inside fenced code or ordinary prose stays literal.
|
Task-list markers such as `- [ ]` and `- [x]` display as atomic inline native checkboxes, including nested tasks. Indentation and following whitespace remain editable text; selecting a checkbox selects the whole marker, with no hidden source characters. Click to toggle completion without moving the selection. Backspace immediately after a checkbox expands its original marker and deletes its final character; forward Delete immediately before it expands the marker and deletes its first character. Each edit is one Undo step. Removing a task’s preceding newline after prose makes its marker literal inline text; joining onto an empty or indentation-only line keeps a valid checkbox. Saved notes and copied text retain the original Markdown spelling, case, and spacing, alongside embedded images. Task syntax inside fenced code or ordinary prose stays literal.
|
||||||
|
|
||||||
Fenced code uses [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) (Highlight.js) for language-aware token colors. Add a language after the opening backticks or tildes, for example `py`, `python`, `swift`, `js`, or `json`. Unlabeled fences use automatic detection; unknown languages remain plain monospace. Highlighting runs entirely offline.
|
Fenced code uses [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) (Highlight.js) for language-aware token colors. Add a language after the opening backticks or tildes, for example `py`, `python`, `swift`, `js`, or `json`. Unlabeled fences use automatic detection; unknown languages remain plain monospace. Highlighting runs entirely offline.
|
||||||
|
|
||||||
|
|||||||
@@ -144,15 +144,15 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||||
guard textView === editor, editor.isEditable,
|
guard textView === editor, editor.isEditable,
|
||||||
commandSelector == #selector(NSResponder.insertNewline(_:)) else { return false }
|
commandSelector == #selector(NSResponder.insertNewline(_:)) else { return false }
|
||||||
let source = editor.string as NSString
|
let source = editor.markdownString as NSString
|
||||||
let selection = editor.selectedRange()
|
let selection = editor.markdownRange(for: editor.selectedRange())
|
||||||
var start = 0, end = 0
|
var start = 0, end = 0
|
||||||
source.getLineStart(&start, end: nil, contentsEnd: &end,
|
source.getLineStart(&start, end: nil, contentsEnd: &end,
|
||||||
for: NSRange(location: selection.location, length: 0))
|
for: NSRange(location: selection.location, length: 0))
|
||||||
let line = source.substring(with: NSRange(location: start, length: end - start)) as NSString
|
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)),
|
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,
|
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)
|
selection.location >= $0.body.location && selection.location <= NSMaxRange($0.body)
|
||||||
}) else { return false }
|
}) else { return false }
|
||||||
let indent = line.substring(with: item.range(at: 1))
|
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 }
|
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 {
|
} else {
|
||||||
guard let prefix = Self.continuedPrefix(item, in: line) else { return false }
|
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
|
return true
|
||||||
}
|
}
|
||||||
@@ -195,7 +197,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
func save() -> Bool {
|
func save() -> Bool {
|
||||||
guard isDirty, let store, let selectedID else { return true }
|
guard isDirty, let store, let selectedID else { return true }
|
||||||
do {
|
do {
|
||||||
try store.update(id: selectedID, contents: editor.attributedString())
|
try store.update(id: selectedID, contents: editor.markdownContents)
|
||||||
isDirty = false
|
isDirty = false
|
||||||
status.stringValue = "Saved locally · Markdown · Paste or drop images"
|
status.stringValue = "Saved locally · Markdown · Paste or drop images"
|
||||||
status.textColor = SunsetPalette.secondaryText
|
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 }
|
guard let note = store?.notes.first(where: { $0.id == id }) else { return }
|
||||||
selectedID = id
|
selectedID = id
|
||||||
defaults.set(id.uuidString, forKey: "selectedNote")
|
defaults.set(id.uuidString, forKey: "selectedNote")
|
||||||
editor.textStorage?.setAttributedString(note.contents)
|
editor.setMarkdownContents(note.contents)
|
||||||
editor.undoManager?.removeAllActions()
|
editor.undoManager?.removeAllActions()
|
||||||
editor.isEditable = true
|
editor.isEditable = true
|
||||||
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
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
|
@MainActor
|
||||||
enum MarkdownHighlighting {
|
enum MarkdownHighlighting {
|
||||||
@@ -399,7 +320,7 @@ enum MarkdownHighlighting {
|
|||||||
}()
|
}()
|
||||||
private static let openingFence = try! NSRegularExpression(pattern: "(?m)^ {0,3}(`{3,}|~{3,})([^\\r\\n]*)\\r?\\n")
|
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
|
let source = text as NSString
|
||||||
var blocks: [(NSRange, NSRange, String?)] = []
|
var blocks: [(NSRange, NSRange, String?)] = []
|
||||||
var cursor = 0
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,7 +95,7 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws
|
|||||||
reopenedWindow.contentViewController = reopened
|
reopenedWindow.contentViewController = reopened
|
||||||
defer { _ = reopened.close(); reopenedWindow.orderOut(nil) }
|
defer { _ = reopened.close(); reopenedWindow.orderOut(nil) }
|
||||||
let reopenedViews = descendants(reopened.view)
|
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)
|
#expect(reopenedEditor.string == first.contents.string)
|
||||||
// Removing Markdown must also remove its previous visual style without changing the source.
|
// 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))
|
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) }
|
defer { try? FileManager.default.removeItem(at: backup) }
|
||||||
try FileManager.default.moveItem(at: directory, to: backup)
|
try FileManager.default.moveItem(at: directory, to: backup)
|
||||||
try Data("Blocked storage".utf8).write(to: directory)
|
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
|
var closed = false
|
||||||
reopened.closed = { closed = true }
|
reopened.closed = { closed = true }
|
||||||
reopened.goBack()
|
reopened.goBack()
|
||||||
@@ -114,7 +114,8 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws
|
|||||||
reopened.browseNotes()
|
reopened.browseNotes()
|
||||||
filter(try picker(reopened), "scnd")
|
filter(try picker(reopened), "scnd")
|
||||||
#expect(reopened.handle(#selector(NSResponder.insertNewline(_:))))
|
#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)
|
#expect(try selectedID() == firstID)
|
||||||
reopened.browseNotes()
|
reopened.browseNotes()
|
||||||
#expect(!reopened.close())
|
#expect(!reopened.close())
|
||||||
@@ -123,6 +124,7 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws
|
|||||||
try FileManager.default.removeItem(at: directory)
|
try FileManager.default.removeItem(at: directory)
|
||||||
try FileManager.default.moveItem(at: backup, to: directory)
|
try FileManager.default.moveItem(at: backup, to: directory)
|
||||||
#expect(reopened.save())
|
#expect(reopened.save())
|
||||||
|
#expect(try NotesStore(directory: directory).notes.first { $0.id == firstID }?.contents.string == "- [X] Unsaved")
|
||||||
#expect(reopened.close())
|
#expect(reopened.close())
|
||||||
#expect(closed)
|
#expect(closed)
|
||||||
#expect(!descendants(reopened.view).contains { ($0 as? NSSearchField)?.accessibilityLabel() == "Search notes" })
|
#expect(!descendants(reopened.view).contains { ($0 as? NSSearchField)?.accessibilityLabel() == "Search notes" })
|
||||||
@@ -247,7 +249,7 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
|
|||||||
window.contentViewController = controller
|
window.contentViewController = controller
|
||||||
defer { _ = controller.close(); window.orderOut(nil) }
|
defer { _ = controller.close(); window.orderOut(nil) }
|
||||||
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
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)
|
window.makeFirstResponder(editor)
|
||||||
let enter = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
|
let enter = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
|
||||||
timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "\r",
|
timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "\r",
|
||||||
@@ -264,7 +266,10 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
|
|||||||
("9. ninth", "9. ninth\n10. "),
|
("9. ninth", "9. ninth\n10. "),
|
||||||
(" 12) nested", " 12) nested\n 13) "),
|
(" 12) nested", " 12) nested\n 13) "),
|
||||||
("- [x] finished", "- [x] finished\n- [ ] "),
|
("- [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- "),
|
("- parent\n - ", "- parent\n- "),
|
||||||
("1. parent\n * ", "1. parent\n2. "),
|
("1. parent\n * ", "1. parent\n2. "),
|
||||||
("- parent\n\t+ child\n\t\t3) ", "- parent\n\t+ child\n\t+ "),
|
("- 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 {
|
for (before, after) in examples {
|
||||||
reset(before)
|
reset(before)
|
||||||
editor.keyDown(with: enter)
|
editor.keyDown(with: enter)
|
||||||
#expect(editor.string == after, "Return in \(before.debugDescription)")
|
#expect(editor.markdownString == after, "Return in \(before.debugDescription)")
|
||||||
#expect(editor.selectedRange() == NSRange(location: after.utf16.count, length: 0))
|
#expect(editor.markdownContents.string == after)
|
||||||
|
#expect(editor.selectedRange() == NSRange(location: editor.string.utf16.count, length: 0))
|
||||||
}
|
}
|
||||||
reset("- ab🦊cd")
|
reset("- ab🦊cd")
|
||||||
editor.setSelectedRange((editor.string as NSString).range(of: "🦊"))
|
editor.setSelectedRange((editor.string as NSString).range(of: "🦊"))
|
||||||
editor.keyDown(with: enter)
|
editor.keyDown(with: enter)
|
||||||
#expect(editor.string == "- ab\n- cd")
|
#expect(editor.string == "- ab\n- cd")
|
||||||
#expect(editor.selectedRange() == NSRange(location: 7, length: 0))
|
#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")
|
reset("- parent\n - child\n - leaf")
|
||||||
editor.keyDown(with: enter)
|
editor.keyDown(with: enter)
|
||||||
#expect(editor.string == "- parent\n - child\n - leaf\n - ")
|
#expect(editor.string == "- parent\n - child\n - leaf\n - ")
|
||||||
@@ -310,6 +322,17 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
|
|||||||
}
|
}
|
||||||
editor.keyDown(with: enter)
|
editor.keyDown(with: enter)
|
||||||
#expect(editor.string == before + "\n")
|
#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")
|
reset("7. saved")
|
||||||
editor.keyDown(with: enter)
|
editor.keyDown(with: enter)
|
||||||
editor.breakUndoCoalescing()
|
editor.breakUndoCoalescing()
|
||||||
@@ -341,10 +364,14 @@ func taskCheckboxesToggleMarkdownWithUndoPersistenceAndListContinuation() throws
|
|||||||
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||||
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
|
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
|
||||||
window.makeFirstResponder(editor)
|
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.insertText(before, replacementRange: editor.selectedRange())
|
||||||
editor.breakUndoCoalescing()
|
editor.breakUndoCoalescing()
|
||||||
editor.undoManager?.removeAllActions()
|
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 }
|
let boxes = editor.subviews.compactMap { $0 as? NSButton }
|
||||||
#expect(boxes.map(\.state) == [.off, .on])
|
#expect(boxes.map(\.state) == [.off, .on])
|
||||||
#expect(boxes.first?.cell?.accessibilityRole() == .checkBox)
|
#expect(boxes.first?.cell?.accessibilityRole() == .checkBox)
|
||||||
@@ -353,19 +380,22 @@ func taskCheckboxesToggleMarkdownWithUndoPersistenceAndListContinuation() throws
|
|||||||
let first = try #require(boxes.first)
|
let first = try #require(boxes.first)
|
||||||
first.performClick(nil)
|
first.performClick(nil)
|
||||||
let checked = before.replacingOccurrences(of: "- [ ] buy", with: "- [x] buy")
|
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(first.state == .on)
|
||||||
#expect(editor.selectedRange() == selection)
|
#expect(editor.selectedRange() == selection)
|
||||||
let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:)))
|
let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:)))
|
||||||
let saved = try #require(NotesStore(directory: directory).notes.first { $0.id == id })
|
let saved = try #require(NotesStore(directory: directory).notes.first { $0.id == id })
|
||||||
#expect(saved.contents.string == checked)
|
#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()
|
editor.undoManager?.undo()
|
||||||
#expect(editor.string == before)
|
#expect(editor.markdownString == before)
|
||||||
#expect(first.state == .off)
|
#expect(editor.string == rendered)
|
||||||
|
#expect(editor.subviews.compactMap { $0 as? NSButton }.first?.state == .off)
|
||||||
editor.undoManager?.redo()
|
editor.undoManager?.redo()
|
||||||
#expect(editor.string == checked)
|
#expect(editor.markdownString == checked)
|
||||||
#expect(first.state == .on)
|
#expect(editor.string == rendered)
|
||||||
|
#expect(editor.subviews.compactMap { $0 as? NSButton }.first?.state == .on)
|
||||||
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
|
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
|
||||||
let board = NSPasteboard.withUniqueName()
|
let board = NSPasteboard.withUniqueName()
|
||||||
defer { board.releaseGlobally() }
|
defer { board.releaseGlobally() }
|
||||||
@@ -374,16 +404,188 @@ func taskCheckboxesToggleMarkdownWithUndoPersistenceAndListContinuation() throws
|
|||||||
let reopened = NotesController(defaults: defaults, directory: directory)
|
let reopened = NotesController(defaults: defaults, directory: directory)
|
||||||
defer { _ = reopened.close() }
|
defer { _ = reopened.close() }
|
||||||
let restoredEditor = try #require(descendants(reopened.view).compactMap { $0 as? NotesTextView }.first)
|
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])
|
#expect(restoredEditor.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.on, .on])
|
||||||
editor.insertText("- [x] finished", replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
|
editor.insertText("- [x] finished", replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
|
||||||
let enter = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
|
let enter = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
|
||||||
timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "\r",
|
timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "\r",
|
||||||
charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36))
|
charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36))
|
||||||
editor.keyDown(with: enter)
|
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])
|
#expect(editor.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.on, .off])
|
||||||
editor.keyDown(with: enter)
|
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])
|
#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