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