style: align rendered bullet and numbered lists
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user