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
+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