style: align rendered bullet and numbered lists

This commit is contained in:
Valdemar Lorenzen
2026-10-19 12:47:20 +02:00
parent f275695d2f
commit 85425ee71b
3 changed files with 385 additions and 62 deletions
+2 -2
View File
@@ -139,11 +139,11 @@ 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.
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.
Edit Markdown directly: headings, bold, italic, lists, links, inline code, and fenced code blocks are highlighted while the source remains visible, except for list markers rendered as inline tokens. Bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and native checkbox markers share a marker column, so item text aligns at the same nesting depth even with long numbers; wrapped lines align with the item text, not the marker. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues 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.
Checkbox conversion waits for a space or tab after the closing bracket: type `- [ ] `, `- [] `, or `- [x] `. A closing bracket alone remains literal, so typing directly after it does not briefly create and then remove a checkbox. Both `[]` and `[ ]` mean unchecked; toggling a compact `[]` task on produces `[x]`, and toggling it off produces `[ ]`.
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.
List markers are atomic inline tokens, including nested bullets and numbers; task markers such as `- [ ]` and `- [x]` use native checkboxes. Indentation and following whitespace remain editable source text without changing the aligned body column; selecting a token selects the whole marker, with no hidden source characters. Click a checkbox to toggle completion without moving the selection. Backspace immediately after a token 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 an item's preceding newline after prose makes its marker literal inline text; joining onto an empty or indentation-only line keeps a valid list token. Saved notes and copied text retain the original Markdown spelling, numbers, case, and spacing, alongside embedded images; alignment formatting is display-only. Thematic breaks and list syntax inside fenced code or ordinary prose stay 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.
+198 -43
View File
@@ -2,34 +2,49 @@ import AppKit
private let literalTaskMarker = NSAttributedString.Key("Conjure.literalTaskMarker")
private let sourceTaskAttachment = NSAttributedString.Key("Conjure.sourceTaskAttachment")
private let sourceListParagraph = NSAttributedString.Key("Conjure.sourceListParagraph")
private let listWhitespace = NSAttributedString.Key("Conjure.listWhitespace")
private final class TaskAttachment: NSTextAttachment {
let marker: String
private final class ListAttachment: NSTextAttachment {
let source: NSAttributedString
let isTask: Bool
var marker: String { source.string }
var checked: Bool { marker.hasSuffix("[x]") || marker.hasSuffix("[X]") }
@MainActor
init(marker: String) {
self.marker = marker
init(source: NSAttributedString, isTask: Bool) {
self.source = source
self.isTask = isTask
super.init(data: nil, ofType: nil)
attachmentCell = TaskAttachmentCell(textCell: "")
let cell = ListAttachmentCell(textCell: isTask ? "" : (source.length == 1 ? "•" : source.string))
cell.attributedStringValue = NSAttributedString(string: cell.stringValue,
attributes: [.font: NSFont.systemFont(ofSize: 14), .foregroundColor: SunsetPalette.accent])
cell.alignment = .right
attachmentCell = cell
bounds = NSRect(x: 0, y: -3, width: 18, height: 17)
}
required init?(coder: NSCoder) { fatalError("Task attachments are serialized as Markdown") }
required init?(coder: NSCoder) { fatalError("List attachments are serialized as Markdown") }
}
private final class TaskAttachmentCell: NSTextAttachmentCell {
nonisolated override func cellSize() -> NSSize { NSSize(width: 18, height: 17) }
private final class ListAttachmentCell: NSTextAttachmentCell {
nonisolated(unsafe) var columnWidth: CGFloat = 18
nonisolated override func cellSize() -> NSSize { NSSize(width: columnWidth, height: 17) }
nonisolated override func cellBaselineOffset() -> NSPoint { NSPoint(x: 0, y: -3) }
override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {}
override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {
guard !stringValue.isEmpty else { return }
var frame = cellFrame
frame.size.width -= 4
super.draw(withFrame: frame, in: controlView)
}
}
@MainActor
final class NotesTextView: NSTextView {
private static let taskPattern = try! NSRegularExpression(
pattern: "(?m)^[ \\t]*((?:[-+*]|[0-9]+[.)])[ \\t]+\\[([ xX]?)\\])(?=[ \\t])"
final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
private static let listPattern = try! NSRegularExpression(
pattern: "(?m)^([ \\t]*)([-+*]|[0-9]+[.)])([ \\t]+)(?:(\\[([ xX]?)\\])(?=[ \\t]))?"
)
private var tasks: [(range: NSRange, attachment: TaskAttachment)] = []
private var tasks: [(range: NSRange, attachment: ListAttachment)] = []
private var checkboxes: [NSButton] = []
var markdownContents: NSAttributedString { Self.serialize(attributedString()) }
@@ -42,16 +57,17 @@ final class NotesTextView: NSTextView {
private static func serialize(_ contents: NSAttributedString, keepingMetadata: Bool = false) -> NSAttributedString {
let result = NSMutableAttributedString(attributedString: contents)
var replacements: [(NSRange, TaskAttachment)] = []
restoreListLayout(in: result)
var replacements: [(NSRange, ListAttachment)] = []
contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: contents.length)) { value, range, _ in
if let task = value as? TaskAttachment { replacements.append((range, task)) }
if let task = value as? ListAttachment { 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))
let marker = NSMutableAttributedString(attributedString: task.source)
let markerRange = NSRange(location: 0, length: marker.length)
marker.removeAttribute(literalTaskMarker, range: markerRange)
if keepingMetadata { marker.addAttribute(sourceTaskAttachment, value: task, range: markerRange) }
result.replaceCharacters(in: range, with: marker)
}
if !keepingMetadata {
let range = NSRange(location: 0, length: result.length)
@@ -61,20 +77,53 @@ final class NotesTextView: NSTextView {
return result
}
// ponytail: parse the whole note on edits; incremental parsing if large task lists make typing slow.
private static func restoreListLayout(in contents: NSMutableAttributedString) {
let range = NSRange(location: 0, length: contents.length)
contents.enumerateAttribute(sourceListParagraph, in: range) { value, range, _ in
guard let value else { return }
if let style = value as? NSParagraphStyle {
contents.addAttribute(.paragraphStyle, value: style, range: range)
} else {
contents.removeAttribute(.paragraphStyle, range: range)
}
}
contents.removeAttribute(sourceListParagraph, range: range)
contents.removeAttribute(listWhitespace, range: range)
}
// ponytail: parse the whole note on edits; incremental parsing if large 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.
// Explicit values stop AppKit inheriting display-only attributes from replaced list text.
result.addAttribute(listWhitespace, value: false, range: range)
contents.enumerateAttribute(.paragraphStyle, in: range) { style, run, _ in
result.addAttribute(sourceListParagraph, value: style ?? NSNull(), range: run)
}
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)
for match in listPattern.matches(in: contents.string, range: range).reversed() {
let isTask = match.range(at: 4).location != NSNotFound
var markerRange = match.range(at: 2)
if isTask { markerRange.length = NSMaxRange(match.range(at: 4)) - markerRange.location }
let line = source.lineRange(for: match.range)
if !isTask {
let body = source.substring(with: NSRange(location: NSMaxRange(match.range), length: NSMaxRange(line) - NSMaxRange(match.range)))
.trimmingCharacters(in: .newlines)
// Leave a task's bracket prefix editable until its closing bracket has a separator.
if ["[", "[ ", "[x", "[X"].contains(body)
|| ["[]", "[ ]", "[x]", "[X]"].contains(where: { body.hasPrefix($0) }) { continue }
let marker = source.substring(with: markerRange)
if marker == "-" || marker == "*" {
let characters = source.substring(with: line).filter { !$0.isWhitespace }
if characters.count >= 3, characters.allSatisfy({ String($0) == marker }) { continue }
}
}
guard !code.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
var literal = false
contents.enumerateAttribute(literalTaskMarker, in: markerRange) { value, _, stop in
@@ -82,13 +131,22 @@ final class NotesTextView: NSTextView {
}
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 existing = contents.attribute(sourceTaskAttachment, at: markerRange.location, effectiveRange: nil) as? ListAttachment
let attachment: ListAttachment
if let existing, existing.marker == marker {
attachment = existing
} else {
let markerSource = NSMutableAttributedString(attributedString: contents.attributedSubstring(from: markerRange))
markerSource.removeAttribute(sourceTaskAttachment, range: NSRange(location: 0, length: markerSource.length))
attachment = ListAttachment(source: markerSource, isTask: isTask)
}
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
attributes[listWhitespace] = false
attributes[sourceListParagraph] = attributes[.paragraphStyle] ?? NSNull()
token.addAttributes(attributes, range: NSRange(location: 0, length: 1))
result.replaceCharacters(in: markerRange, with: token)
}
@@ -130,6 +188,10 @@ final class NotesTextView: NSTextView {
attributes.removeValue(forKey: .attachment)
attributes.removeValue(forKey: literalTaskMarker)
attributes.removeValue(forKey: sourceTaskAttachment)
if let original = attributes.removeValue(forKey: sourceListParagraph) {
attributes[.paragraphStyle] = original as? NSParagraphStyle
}
attributes.removeValue(forKey: listWhitespace)
return attributes
}
@@ -160,7 +222,7 @@ final class NotesTextView: NSTextView {
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 {
if let lhs = lhs as? ListAttachment, let rhs = rhs as? ListAttachment {
return lhs.marker == rhs.marker
}
guard lhs === rhs else { return false }
@@ -214,16 +276,47 @@ final class NotesTextView: NSTextView {
}
}
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
private func expand(_ task: (range: NSRange, attachment: ListAttachment), backwards: Bool) {
let marker = task.attachment.source
let remaining = NSRange(location: backwards ? 0 : 1, length: marker.length - 1)
let text = NSMutableAttributedString(attributedString: marker.attributedSubstring(from: remaining))
text.addAttribute(literalTaskMarker, value: true, range: NSRange(location: 0, length: text.length))
let range = markdownRange(for: task.range)
replaceSource(NSAttributedString(string: text, attributes: attributes), range: range, semantic: true)
replaceSource(text, range: range, semantic: true)
if !backwards { setSelectedRange(NSRange(location: task.range.location, length: 0)) }
}
// Null layout glyphs must still be individual editable source positions.
private func moveAcrossListPrefix(forward: Bool, selecting: Bool) -> Bool {
let selection = selectedRange()
guard selection.length == 0 else { return false }
let source = string as NSString
let index = forward ? selection.location : selection.location - 1
guard index >= 0, index < source.length else { return false }
let attributes = textStorage?.attributes(at: index, effectiveRange: nil) ?? [:]
guard attributes[.attachment] is ListAttachment || attributes[listWhitespace] as? Bool == true else { return false }
let character = source.rangeOfComposedCharacterSequence(at: index)
setSelectedRange(selecting ? character
: NSRange(location: forward ? NSMaxRange(character) : character.location, length: 0))
return true
}
override func moveRight(_ sender: Any?) {
if !moveAcrossListPrefix(forward: true, selecting: false) { super.moveRight(sender) }
}
override func moveLeft(_ sender: Any?) {
if !moveAcrossListPrefix(forward: false, selecting: false) { super.moveLeft(sender) }
}
override func moveRightAndModifySelection(_ sender: Any?) {
if !moveAcrossListPrefix(forward: true, selecting: true) { super.moveRightAndModifySelection(sender) }
}
override func moveLeftAndModifySelection(_ sender: Any?) {
if !moveAcrossListPrefix(forward: false, selecting: true) { super.moveLeftAndModifySelection(sender) }
}
private func markdownSelectionWriter() -> NSTextView {
let writer = NSTextView()
writer.isRichText = isRichText
@@ -268,14 +361,15 @@ final class NotesTextView: NSTextView {
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)) }
if let attachment = value as? ListAttachment { tasks.append((range, attachment)) }
}
while checkboxes.count > tasks.count {
let taskIndices = tasks.indices.filter { tasks[$0].attachment.isTask }
while checkboxes.count > taskIndices.count {
let checkbox = checkboxes.removeLast()
if window?.firstResponder === checkbox { window?.makeFirstResponder(self) }
checkbox.removeFromSuperview()
}
while checkboxes.count < tasks.count {
while checkboxes.count < taskIndices.count {
let checkbox = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:)))
checkbox.controlSize = .small
checkbox.contentTintColor = SunsetPalette.accent
@@ -284,9 +378,10 @@ final class NotesTextView: NSTextView {
addSubview(checkbox)
}
let source = string as NSString
for (index, task) in tasks.enumerated() {
for (index, taskIndex) in taskIndices.enumerated() {
let task = tasks[taskIndex]
let checkbox = checkboxes[index]
checkbox.tag = index
checkbox.tag = taskIndex
checkbox.state = task.attachment.checked ? .on : .off
checkbox.isEnabled = isEditable
let end = NSMaxRange(source.lineRange(for: task.range))
@@ -295,28 +390,88 @@ final class NotesTextView: NSTextView {
checkbox.setAccessibilityLabel(title.isEmpty ? "Task" : title)
checkbox.toolTip = title
}
applyListLayout()
positionCheckboxes()
}
private func applyListLayout() {
guard let storage = textStorage, let layoutManager else { return }
let range = NSRange(location: 0, length: storage.length)
layoutManager.delegate = self
storage.beginEditing()
Self.restoreListLayout(in: storage)
let markerFont = NSFont.systemFont(ofSize: 14)
let column = ceil(tasks.reduce(CGFloat(18)) { width, task in
guard !task.attachment.isTask else { return width }
return max(width, (task.attachment.marker as NSString).size(withAttributes: [.font: markerFont]).width + 4)
})
let space = (" " as NSString).size(withAttributes: [.font: markerFont]).width
let source = storage.string as NSString
for task in tasks {
let paragraph = source.paragraphRange(for: task.range)
let indentation = source.substring(with: NSRange(location: paragraph.location, length: task.range.location - paragraph.location))
let depth = indentation.reduce(0) { $1 == "\t" ? $0 + 4 - $0 % 4 : $0 + 1 }
let style = (storage.attribute(.paragraphStyle, at: task.range.location, effectiveRange: nil) as? NSParagraphStyle
?? NSParagraphStyle.default).mutableCopy() as! NSMutableParagraphStyle
storage.enumerateAttribute(.paragraphStyle, in: paragraph) { original, run, _ in
storage.addAttribute(sourceListParagraph, value: original ?? NSNull(), range: run)
}
// Keep indentation as native whitespace; null paragraph-leading glyphs lose first-line layout.
style.firstLineHeadIndent = 0
style.headIndent = CGFloat(depth) * space + column
style.tabStops = []
style.defaultTabInterval = 4 * space
storage.addAttribute(.paragraphStyle, value: style, range: paragraph)
var end = NSMaxRange(task.range)
while end < NSMaxRange(paragraph) && (source.character(at: end) == 32 || source.character(at: end) == 9) { end += 1 }
storage.addAttribute(listWhitespace, value: true,
range: NSRange(location: NSMaxRange(task.range), length: end - NSMaxRange(task.range)))
(task.attachment.attachmentCell as? ListAttachmentCell)?.columnWidth = column
task.attachment.bounds.size.width = column
}
storage.endEditing()
// Whitespace remains editable source, but occupies no part of the native marker column.
layoutManager.invalidateGlyphs(forCharacterRange: range, changeInLength: 0, actualCharacterRange: nil)
}
func layoutManager(_ layoutManager: NSLayoutManager, shouldGenerateGlyphs glyphs: UnsafePointer<CGGlyph>,
properties props: UnsafePointer<NSLayoutManager.GlyphProperty>,
characterIndexes charIndexes: UnsafePointer<Int>, font aFont: NSFont,
forGlyphRange glyphRange: NSRange) -> Int {
guard let storage = layoutManager.textStorage,
(0..<glyphRange.length).contains(where: {
storage.attribute(listWhitespace, at: charIndexes[$0], effectiveRange: nil) as? Bool == true
}) else { return 0 }
var properties = Array(UnsafeBufferPointer(start: props, count: glyphRange.length))
for index in properties.indices {
if storage.attribute(listWhitespace, at: charIndexes[index], effectiveRange: nil) as? Bool == true {
properties[index] = .null
}
}
layoutManager.setGlyphs(glyphs, properties: &properties, characterIndexes: charIndexes,
font: aFont, forGlyphRange: glyphRange)
return glyphRange.length
}
override func layout() {
super.layout()
positionCheckboxes()
}
private func positionCheckboxes() {
guard let layoutManager, let textContainer else { return }
guard !checkboxes.isEmpty, let layoutManager, let textContainer else { return }
layoutManager.ensureLayout(for: textContainer)
for (index, task) in tasks.enumerated() {
for checkbox in checkboxes {
let task = tasks[checkbox.tag]
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))
checkbox.setFrameOrigin(NSPoint(x: rect.maxX - 18, y: rect.midY - checkbox.frame.height / 2))
}
}
@objc private func toggleTask(_ sender: NSButton) {
guard isEditable, tasks.indices.contains(sender.tag) else { return }
guard isEditable, tasks.indices.contains(sender.tag), tasks[sender.tag].attachment.isTask else { return }
let task = tasks[sender.tag]
let selection = selectedRange()
let marker = task.attachment.marker as NSString
+185 -17
View File
@@ -293,7 +293,8 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
reset("- ab🦊cd")
editor.setSelectedRange((editor.string as NSString).range(of: "🦊"))
editor.keyDown(with: enter)
#expect(editor.string == "- ab\n- cd")
#expect(editor.string == "\u{fffc} ab\n\u{fffc} cd")
#expect(editor.markdownString == "- ab\n- cd")
#expect(editor.selectedRange() == NSRange(location: 7, length: 0))
reset("- [X] ab🦊cd")
editor.setSelectedRange((editor.string as NSString).range(of: "🦊"))
@@ -303,25 +304,25 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
#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 - ")
#expect(editor.string == "\u{fffc} parent\n \u{fffc} child\n \u{fffc} leaf\n \u{fffc} ")
editor.keyDown(with: enter)
#expect(editor.string == "- parent\n - child\n - leaf\n - ")
#expect(editor.string == "\u{fffc} parent\n \u{fffc} child\n \u{fffc} leaf\n \u{fffc} ")
editor.keyDown(with: enter)
#expect(editor.string == "- parent\n - child\n - leaf\n- ")
#expect(editor.string == "\u{fffc} parent\n \u{fffc} child\n \u{fffc} leaf\n\u{fffc} ")
editor.keyDown(with: enter)
#expect(editor.string == "- parent\n - child\n - leaf\n")
#expect(editor.string == "\u{fffc} parent\n \u{fffc} child\n \u{fffc} leaf\n")
// Nesting follows actual ancestor indentation, not a fixed depth or indent size.
let ancestors = (0..<80).map { String(repeating: " ", count: $0 * 2) + "- level \($0)" }.joined(separator: "\n")
let before = ancestors + "\n" + String(repeating: " ", count: 160) + "- leaf"
reset(before)
editor.keyDown(with: enter)
#expect(editor.string == before + "\n" + String(repeating: " ", count: 160) + "- ")
#expect(editor.markdownString == before + "\n" + String(repeating: " ", count: 160) + "- ")
for depth in stride(from: 79, through: 0, by: -1) {
editor.keyDown(with: enter)
#expect(editor.string == before + "\n" + String(repeating: " ", count: depth * 2) + "- ")
#expect(editor.markdownString == before + "\n" + String(repeating: " ", count: depth * 2) + "- ")
}
editor.keyDown(with: enter)
#expect(editor.string == before + "\n")
#expect(editor.markdownString == before + "\n")
reset("- [X] parent\n - [ ] ")
editor.keyDown(with: enter)
editor.breakUndoCoalescing()
@@ -336,11 +337,14 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
reset("7. saved")
editor.keyDown(with: enter)
editor.breakUndoCoalescing()
#expect(editor.string == "7. saved\n8. ")
#expect(editor.string == "\u{fffc} saved\n\u{fffc} ")
#expect(editor.markdownString == "7. saved\n8. ")
editor.undoManager?.undo()
#expect(editor.string == "7. saved")
#expect(editor.string == "\u{fffc} saved")
#expect(editor.markdownString == "7. saved")
editor.undoManager?.redo()
#expect(editor.string == "7. saved\n8. ")
#expect(editor.string == "\u{fffc} saved\n\u{fffc} ")
#expect(editor.markdownString == "7. saved\n8. ")
let restored = try NotesStore(directory: directory)
let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:)))
#expect(restored.notes.first { $0.id == id }?.contents.string == "7. saved\n8. ")
@@ -422,7 +426,7 @@ func taskCheckboxesToggleMarkdownWithUndoPersistenceAndListContinuation() throws
}
@Test @MainActor
func atomicTasksExpandAtDeletionBoundariesAndNormalizeLineJoinsInOneUndo() throws {
func atomicListMarkersExpandAtDeletionBoundariesAndNormalizeLineJoinsInOneUndo() throws {
_ = NSApplication.shared
let suite = "NotesTaskDeletion.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
@@ -457,16 +461,22 @@ func atomicTasksExpandAtDeletionBoundariesAndNormalizeLineJoinsInOneUndo() throw
("- [ ] buy", false, " [ ] buy"),
("\t+ [X]\t🦊", false, "\t [X]\t🦊"),
(" 12)\t[x] done", false, " 2)\t[x] done"),
("- buy", true, " buy"),
("\t+\t🦊", true, "\t\t🦊"),
(" 123)\tdone", true, " 123\tdone"),
("* buy", false, " buy"),
(" 123. done", false, " 23. done"),
]
for (source, backward, expanded) in boundaries {
let rendered = reset(source)
let token = (rendered as NSString).range(of: "\u{fffc}")
#expect(token.length == 1)
let marker = editor.markdownRange(for: token)
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
let caret = backward ? NSMaxRange(marker) - 1 : token.location
#expect(editor.selectedRange() == NSRange(location: caret, length: 0))
editor.undoManager?.undo()
#expect(editor.string == rendered)
@@ -477,6 +487,8 @@ func atomicTasksExpandAtDeletionBoundariesAndNormalizeLineJoinsInOneUndo() throw
#expect(editor.markdownString == expanded)
}
let joins = [
("prose\n- item", "prose- item", "prose- item"),
("\n123) item", "123) item", "\u{fffc} item"),
("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"),
@@ -523,7 +535,7 @@ func atomicTasksCopySelectAndPersistMarkdownAlongsideUnicodeAndImages() throws {
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"))
source.append(NSAttributedString(string: "\n - [ ] tail\n+ bullet\n009) numbered"))
let store = try NotesStore(directory: directory)
let id = try store.create()
try store.update(id: id, contents: source)
@@ -536,7 +548,7 @@ func atomicTasksCopySelectAndPersistMarkdownAlongsideUnicodeAndImages() 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 rendered = "\t\u{fffc}\t🦊 \u{fffc}\n \u{fffc} tail"
let rendered = "\t\u{fffc}\t🦊 \u{fffc}\n \u{fffc} tail\n\u{fffc} bullet\n\u{fffc} numbered"
#expect(editor.string == rendered)
#expect(editor.markdownString == source.string)
let token = NSRange(location: 1, length: 1)
@@ -568,7 +580,7 @@ func atomicTasksCopySelectAndPersistMarkdownAlongsideUnicodeAndImages() throws {
editor.setSelectedRange(token)
editor.deleteBackward(nil)
editor.breakUndoCoalescing()
#expect(editor.string == "\t\t🦊 \u{fffc}\n \u{fffc} tail")
#expect(editor.string == "\t\t🦊 \u{fffc}\n \u{fffc} tail\n\u{fffc} bullet\n\u{fffc} numbered")
#expect(editor.markdownString == (source.string as NSString).replacingCharacters(in: marker, with: ""))
editor.undoManager?.undo()
#expect(editor.string == rendered)
@@ -615,7 +627,10 @@ func taskTokensRequireTrailingWhitespaceAndAcceptEmptyBracketsWhileTyping() thro
for character in marker {
typed.append(character)
editor.insertText(String(character), replacementRange: editor.selectedRange())
#expect(editor.string == typed)
let ordinaryPrefix = ["- ", "\t+ ", "12) "].contains(typed)
let indentation = String(typed.prefix { $0 == " " || $0 == "\t" })
#expect(editor.string == (ordinaryPrefix ? indentation + "\u{fffc} " : typed))
#expect(editor.markdownString == typed)
}
editor.insertText(" ", replacementRange: editor.selectedRange())
let indentation = String(marker.prefix { $0 == " " || $0 == "\t" })
@@ -655,4 +670,157 @@ func taskTokensRequireTrailingWhitespaceAndAcceptEmptyBracketsWhileTyping() thro
editor.insertText("- [] ", replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
editor.keyDown(with: enter)
#expect(editor.markdownString.isEmpty)
for source in ["- item", "* item", "+ item", "001) item", "09. item"] {
editor.setMarkdownContents(NSAttributedString(string: ""))
editor.setSelectedRange(NSRange(location: 0, length: 0))
var typed = ""
for character in source {
typed.append(character)
editor.insertText(String(character), replacementRange: editor.selectedRange())
#expect(editor.markdownString == typed)
}
#expect(editor.string == "\u{fffc} item")
#expect(editor.subviews.compactMap { $0 as? NSButton }.isEmpty)
}
for literal in [
"---", "- - -", "* * *", "___", "-word", "1.word", "prose - item",
"```md\n- literal\n123) literal\n```", "~~~\n+ literal\n~~~",
"- [", "- []", "- [ ]", "12) [X]", "- []not a task",
] {
editor.setMarkdownContents(NSAttributedString(string: literal))
#expect(editor.string == literal)
#expect(editor.markdownString == literal)
#expect(editor.subviews.compactMap { $0 as? NSButton }.isEmpty)
}
}
@Test @MainActor
func mixedListMarkersAlignBodyGlyphsAndWrappedLinesWithoutChangingMarkdown() throws {
_ = NSApplication.shared
let suite = "NotesListAlignment.\(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: 520, height: 400),
styleMask: .borderless, backing: .buffered, defer: false)
window.contentViewController = controller
window.setContentSize(NSSize(width: 520, height: 400))
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)
let continuation = String(repeating: " wrapped words", count: 24)
let rows = [
("", "-", " ", "bullet"), ("", "*", "\t", "asterisk"),
("", "+", " ", "plus"), ("", "1.", " ", "ordered"),
("", "123456789)", " ", "long"), ("", "- [ ]", " ", "task"),
(" ", "+", " ", "nestedBullet"), (" ", "00012.", "\t", "nestedNumber"),
(" ", "* [X]", " ", "nestedTask"),
]
let source = rows.map { $0.0 + $0.1 + $0.2 + $0.3 + continuation }.joined(separator: "\n")
editor.insertText(source, replacementRange: editor.selectedRange())
window.contentView?.layoutSubtreeIfNeeded()
let layout = try #require(editor.layoutManager)
let container = try #require(editor.textContainer)
layout.ensureLayout(for: container)
let rendered = editor.string as NSString
func position(_ glyph: Int) -> NSPoint {
let fragment = layout.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil)
let location = layout.location(forGlyphAt: glyph)
return NSPoint(x: fragment.minX + location.x, y: fragment.minY + location.y)
}
var bodyColumns: [CGFloat] = []
for (index, row) in rows.enumerated() {
let body = rendered.range(of: row.3 + continuation)
#expect(body.location != NSNotFound)
let glyphs = layout.glyphRange(forCharacterRange: body, actualCharacterRange: nil)
let column = position(glyphs.location).x
bodyColumns.append(column)
let paragraph = rendered.paragraphRange(for: body)
let marker = rendered.range(of: "\u{fffc}", options: [], range: paragraph)
#expect(marker.length == 1)
let markerGlyphs = layout.glyphRange(forCharacterRange: marker, actualCharacterRange: nil)
#expect(layout.boundingRect(forGlyphRange: markerGlyphs, in: container).maxX <= column + 0.5)
var lines = 0
layout.enumerateLineFragments(forGlyphRange: glyphs) { _, _, _, lineGlyphs, _ in
if lines > 0 {
#expect(abs(position(lineGlyphs.location).x - column) < 0.5,
"Wrapped body column for row \(index)")
}
lines += 1
}
#expect(lines > 1, "Row \(index) must wrap to exercise hanging indentation")
let style = try #require(editor.textStorage?.attribute(.paragraphStyle, at: body.location, effectiveRange: nil) as? NSParagraphStyle)
#expect(style.headIndent > 0)
}
for column in bodyColumns.prefix(6) { #expect(abs(column - bodyColumns[0]) < 0.5) }
for column in bodyColumns.suffix(3) { #expect(abs(column - bodyColumns[6]) < 0.5) }
#expect(bodyColumns[6] > bodyColumns[0])
#expect(editor.subviews.compactMap { $0 as? NSButton }.map(\.state) == [.off, .on])
#expect(editor.markdownString == source)
let sourceMarker = (source as NSString).range(of: "123456789)")
let renderedMarker = editor.renderedRange(for: sourceMarker)
#expect(rendered.substring(with: renderedMarker) == "\u{fffc}")
#expect(editor.markdownRange(for: renderedMarker) == sourceMarker)
let board = NSPasteboard.withUniqueName()
defer { board.releaseGlobally() }
editor.setSelectedRange(renderedMarker)
#expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes))
#expect(board.string(forType: .string) == "123456789)")
editor.setSelectedRange(NSRange(location: 0, length: rendered.length))
board.clearContents()
#expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes))
#expect(board.string(forType: .string) == source)
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)
#expect(controller.save())
let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:)))
let saved = try #require(NotesStore(directory: directory).notes.first { $0.id == id })
for contents in [editor.markdownContents, copied, saved.contents] {
#expect(contents.string == source)
contents.enumerateAttribute(.paragraphStyle, in: NSRange(location: 0, length: contents.length)) { value, _, _ in
let style = value as? NSParagraphStyle
#expect((style?.headIndent ?? 0) == 0)
#expect((style?.firstLineHeadIndent ?? 0) == 0)
}
}
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
editor.setSelectedRange(renderedMarker)
editor.deleteBackward(nil)
editor.breakUndoCoalescing()
#expect(editor.markdownString == (source as NSString).replacingCharacters(in: sourceMarker, with: ""))
editor.undoManager?.undo()
#expect(editor.string == rendered as String)
#expect(editor.markdownString == source)
#expect(editor.undoManager?.canUndo == false)
let markerAttribute = NSAttributedString.Key("ListMarkerSourceTest")
let originalStyle = NSMutableParagraphStyle()
originalStyle.firstLineHeadIndent = 3
originalStyle.headIndent = 7
let attributedSource = NSMutableAttributedString(string: "123) item", attributes: [.paragraphStyle: originalStyle])
attributedSource.addAttribute(markerAttribute, value: "first", range: NSRange(location: 0, length: 1))
attributedSource.addAttribute(markerAttribute, value: "third", range: NSRange(location: 2, length: 1))
editor.setMarkdownContents(attributedSource)
let roundTrip = editor.markdownContents
#expect(roundTrip.attribute(markerAttribute, at: 0, effectiveRange: nil) as? String == "first")
#expect(roundTrip.attribute(markerAttribute, at: 2, effectiveRange: nil) as? String == "third")
let restoredStyle = try #require(roundTrip.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle)
#expect(restoredStyle.firstLineHeadIndent == 3)
#expect(restoredStyle.headIndent == 7)
editor.undoManager?.removeAllActions()
editor.setSelectedRange(NSRange(location: 1, length: 0))
editor.deleteBackward(nil)
editor.breakUndoCoalescing()
#expect(editor.markdownString == "123 item")
#expect(editor.markdownContents.attribute(markerAttribute, at: 2, effectiveRange: nil) as? String == "third")
editor.undoManager?.undo()
#expect(editor.markdownString == attributedSource.string)
#expect(editor.markdownContents.attribute(markerAttribute, at: 0, effectiveRange: nil) as? String == "first")
#expect(editor.markdownContents.attribute(markerAttribute, at: 2, effectiveRange: nil) as? String == "third")
}