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.
|
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 `[ ]`.
|
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.
|
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 literalTaskMarker = NSAttributedString.Key("Conjure.literalTaskMarker")
|
||||||
private let sourceTaskAttachment = NSAttributedString.Key("Conjure.sourceTaskAttachment")
|
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 {
|
private final class ListAttachment: NSTextAttachment {
|
||||||
let marker: String
|
let source: NSAttributedString
|
||||||
|
let isTask: Bool
|
||||||
|
var marker: String { source.string }
|
||||||
var checked: Bool { marker.hasSuffix("[x]") || marker.hasSuffix("[X]") }
|
var checked: Bool { marker.hasSuffix("[x]") || marker.hasSuffix("[X]") }
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
init(marker: String) {
|
init(source: NSAttributedString, isTask: Bool) {
|
||||||
self.marker = marker
|
self.source = source
|
||||||
|
self.isTask = isTask
|
||||||
super.init(data: nil, ofType: nil)
|
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)
|
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 {
|
private final class ListAttachmentCell: NSTextAttachmentCell {
|
||||||
nonisolated override func cellSize() -> NSSize { NSSize(width: 18, height: 17) }
|
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) }
|
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
|
@MainActor
|
||||||
final class NotesTextView: NSTextView {
|
final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
||||||
private static let taskPattern = try! NSRegularExpression(
|
private static let listPattern = try! NSRegularExpression(
|
||||||
pattern: "(?m)^[ \\t]*((?:[-+*]|[0-9]+[.)])[ \\t]+\\[([ xX]?)\\])(?=[ \\t])"
|
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] = []
|
private var checkboxes: [NSButton] = []
|
||||||
|
|
||||||
var markdownContents: NSAttributedString { Self.serialize(attributedString()) }
|
var markdownContents: NSAttributedString { Self.serialize(attributedString()) }
|
||||||
@@ -42,16 +57,17 @@ final class NotesTextView: NSTextView {
|
|||||||
|
|
||||||
private static func serialize(_ contents: NSAttributedString, keepingMetadata: Bool = false) -> NSAttributedString {
|
private static func serialize(_ contents: NSAttributedString, keepingMetadata: Bool = false) -> NSAttributedString {
|
||||||
let result = NSMutableAttributedString(attributedString: contents)
|
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
|
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() {
|
for (range, task) in replacements.reversed() {
|
||||||
var attributes = contents.attributes(at: range.location, effectiveRange: nil)
|
let marker = NSMutableAttributedString(attributedString: task.source)
|
||||||
attributes.removeValue(forKey: .attachment)
|
let markerRange = NSRange(location: 0, length: marker.length)
|
||||||
attributes.removeValue(forKey: literalTaskMarker)
|
marker.removeAttribute(literalTaskMarker, range: markerRange)
|
||||||
if keepingMetadata { attributes[sourceTaskAttachment] = task }
|
if keepingMetadata { marker.addAttribute(sourceTaskAttachment, value: task, range: markerRange) }
|
||||||
result.replaceCharacters(in: range, with: NSAttributedString(string: task.marker, attributes: attributes))
|
result.replaceCharacters(in: range, with: marker)
|
||||||
}
|
}
|
||||||
if !keepingMetadata {
|
if !keepingMetadata {
|
||||||
let range = NSRange(location: 0, length: result.length)
|
let range = NSRange(location: 0, length: result.length)
|
||||||
@@ -61,20 +77,53 @@ final class NotesTextView: NSTextView {
|
|||||||
return result
|
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 {
|
private static func render(_ contents: NSAttributedString) -> NSAttributedString {
|
||||||
let source = contents.string as NSString
|
let source = contents.string as NSString
|
||||||
let range = NSRange(location: 0, length: source.length)
|
let range = NSRange(location: 0, length: source.length)
|
||||||
let code = MarkdownHighlighting.codeBlocks(in: contents.string)
|
let code = MarkdownHighlighting.codeBlocks(in: contents.string)
|
||||||
let result = NSMutableAttributedString(attributedString: contents)
|
let result = NSMutableAttributedString(attributedString: contents)
|
||||||
result.removeAttribute(sourceTaskAttachment, range: range)
|
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)
|
result.addAttribute(literalTaskMarker, value: false, range: range)
|
||||||
contents.enumerateAttribute(literalTaskMarker, in: range) { value, range, _ in
|
contents.enumerateAttribute(literalTaskMarker, in: range) { value, range, _ in
|
||||||
if value as? Bool == true { result.addAttribute(literalTaskMarker, value: true, range: range) }
|
if value as? Bool == true { result.addAttribute(literalTaskMarker, value: true, range: range) }
|
||||||
}
|
}
|
||||||
for match in taskPattern.matches(in: contents.string, range: range).reversed() {
|
for match in listPattern.matches(in: contents.string, range: range).reversed() {
|
||||||
let markerRange = match.range(at: 1)
|
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 }
|
guard !code.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
|
||||||
var literal = false
|
var literal = false
|
||||||
contents.enumerateAttribute(literalTaskMarker, in: markerRange) { value, _, stop in
|
contents.enumerateAttribute(literalTaskMarker, in: markerRange) { value, _, stop in
|
||||||
@@ -82,13 +131,22 @@ final class NotesTextView: NSTextView {
|
|||||||
}
|
}
|
||||||
guard !literal else { continue }
|
guard !literal else { continue }
|
||||||
let marker = source.substring(with: markerRange)
|
let marker = source.substring(with: markerRange)
|
||||||
let existing = contents.attribute(sourceTaskAttachment, at: markerRange.location, effectiveRange: nil) as? TaskAttachment
|
let existing = contents.attribute(sourceTaskAttachment, at: markerRange.location, effectiveRange: nil) as? ListAttachment
|
||||||
let attachment = existing?.marker == marker ? existing! : TaskAttachment(marker: marker)
|
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)
|
let token = NSMutableAttributedString(attachment: attachment)
|
||||||
var attributes = contents.attributes(at: markerRange.location, effectiveRange: nil)
|
var attributes = contents.attributes(at: markerRange.location, effectiveRange: nil)
|
||||||
attributes.removeValue(forKey: .attachment)
|
attributes.removeValue(forKey: .attachment)
|
||||||
attributes.removeValue(forKey: sourceTaskAttachment)
|
attributes.removeValue(forKey: sourceTaskAttachment)
|
||||||
attributes[literalTaskMarker] = false
|
attributes[literalTaskMarker] = false
|
||||||
|
attributes[listWhitespace] = false
|
||||||
|
attributes[sourceListParagraph] = attributes[.paragraphStyle] ?? NSNull()
|
||||||
token.addAttributes(attributes, range: NSRange(location: 0, length: 1))
|
token.addAttributes(attributes, range: NSRange(location: 0, length: 1))
|
||||||
result.replaceCharacters(in: markerRange, with: token)
|
result.replaceCharacters(in: markerRange, with: token)
|
||||||
}
|
}
|
||||||
@@ -130,6 +188,10 @@ final class NotesTextView: NSTextView {
|
|||||||
attributes.removeValue(forKey: .attachment)
|
attributes.removeValue(forKey: .attachment)
|
||||||
attributes.removeValue(forKey: literalTaskMarker)
|
attributes.removeValue(forKey: literalTaskMarker)
|
||||||
attributes.removeValue(forKey: sourceTaskAttachment)
|
attributes.removeValue(forKey: sourceTaskAttachment)
|
||||||
|
if let original = attributes.removeValue(forKey: sourceListParagraph) {
|
||||||
|
attributes[.paragraphStyle] = original as? NSParagraphStyle
|
||||||
|
}
|
||||||
|
attributes.removeValue(forKey: listWhitespace)
|
||||||
return attributes
|
return attributes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +222,7 @@ final class NotesTextView: NSTextView {
|
|||||||
guard old.character(at: left) == new.character(at: right) else { return false }
|
guard old.character(at: left) == new.character(at: right) else { return false }
|
||||||
let lhs = before.attribute(.attachment, at: left, effectiveRange: nil) as? NSTextAttachment
|
let lhs = before.attribute(.attachment, at: left, effectiveRange: nil) as? NSTextAttachment
|
||||||
let rhs = rendered.attribute(.attachment, at: right, 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
|
return lhs.marker == rhs.marker
|
||||||
}
|
}
|
||||||
guard lhs === rhs else { return false }
|
guard lhs === rhs else { return false }
|
||||||
@@ -214,16 +276,47 @@ final class NotesTextView: NSTextView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func expand(_ task: (range: NSRange, attachment: TaskAttachment), backwards: Bool) {
|
private func expand(_ task: (range: NSRange, attachment: ListAttachment), backwards: Bool) {
|
||||||
let marker = task.attachment.marker as NSString
|
let marker = task.attachment.source
|
||||||
let text = backwards ? marker.substring(to: marker.length - 1) : marker.substring(from: 1)
|
let remaining = NSRange(location: backwards ? 0 : 1, length: marker.length - 1)
|
||||||
var attributes = textTypingAttributes
|
let text = NSMutableAttributedString(attributedString: marker.attributedSubstring(from: remaining))
|
||||||
attributes[literalTaskMarker] = true
|
text.addAttribute(literalTaskMarker, value: true, range: NSRange(location: 0, length: text.length))
|
||||||
let range = markdownRange(for: task.range)
|
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)) }
|
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 {
|
private func markdownSelectionWriter() -> NSTextView {
|
||||||
let writer = NSTextView()
|
let writer = NSTextView()
|
||||||
writer.isRichText = isRichText
|
writer.isRichText = isRichText
|
||||||
@@ -268,14 +361,15 @@ final class NotesTextView: NSTextView {
|
|||||||
tasks = []
|
tasks = []
|
||||||
let contents = attributedString()
|
let contents = attributedString()
|
||||||
contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: contents.length)) { value, range, _ in
|
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()
|
let checkbox = checkboxes.removeLast()
|
||||||
if window?.firstResponder === checkbox { window?.makeFirstResponder(self) }
|
if window?.firstResponder === checkbox { window?.makeFirstResponder(self) }
|
||||||
checkbox.removeFromSuperview()
|
checkbox.removeFromSuperview()
|
||||||
}
|
}
|
||||||
while checkboxes.count < tasks.count {
|
while checkboxes.count < taskIndices.count {
|
||||||
let checkbox = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:)))
|
let checkbox = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:)))
|
||||||
checkbox.controlSize = .small
|
checkbox.controlSize = .small
|
||||||
checkbox.contentTintColor = SunsetPalette.accent
|
checkbox.contentTintColor = SunsetPalette.accent
|
||||||
@@ -284,9 +378,10 @@ final class NotesTextView: NSTextView {
|
|||||||
addSubview(checkbox)
|
addSubview(checkbox)
|
||||||
}
|
}
|
||||||
let source = string as NSString
|
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]
|
let checkbox = checkboxes[index]
|
||||||
checkbox.tag = index
|
checkbox.tag = taskIndex
|
||||||
checkbox.state = task.attachment.checked ? .on : .off
|
checkbox.state = task.attachment.checked ? .on : .off
|
||||||
checkbox.isEnabled = isEditable
|
checkbox.isEnabled = isEditable
|
||||||
let end = NSMaxRange(source.lineRange(for: task.range))
|
let end = NSMaxRange(source.lineRange(for: task.range))
|
||||||
@@ -295,28 +390,88 @@ final class NotesTextView: NSTextView {
|
|||||||
checkbox.setAccessibilityLabel(title.isEmpty ? "Task" : title)
|
checkbox.setAccessibilityLabel(title.isEmpty ? "Task" : title)
|
||||||
checkbox.toolTip = title
|
checkbox.toolTip = title
|
||||||
}
|
}
|
||||||
|
applyListLayout()
|
||||||
positionCheckboxes()
|
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() {
|
override func layout() {
|
||||||
super.layout()
|
super.layout()
|
||||||
positionCheckboxes()
|
positionCheckboxes()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func positionCheckboxes() {
|
private func positionCheckboxes() {
|
||||||
guard let layoutManager, let textContainer else { return }
|
guard !checkboxes.isEmpty, let layoutManager, let textContainer else { return }
|
||||||
layoutManager.ensureLayout(for: textContainer)
|
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 glyphs = layoutManager.glyphRange(forCharacterRange: task.range, actualCharacterRange: nil)
|
||||||
let rect = layoutManager.boundingRect(forGlyphRange: glyphs, in: textContainer)
|
let rect = layoutManager.boundingRect(forGlyphRange: glyphs, in: textContainer)
|
||||||
.offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y)
|
.offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y)
|
||||||
let checkbox = checkboxes[index]
|
checkbox.setFrameOrigin(NSPoint(x: rect.maxX - 18, y: rect.midY - checkbox.frame.height / 2))
|
||||||
checkbox.setFrameOrigin(NSPoint(x: rect.minX, y: rect.midY - checkbox.frame.height / 2))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func toggleTask(_ sender: NSButton) {
|
@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 task = tasks[sender.tag]
|
||||||
let selection = selectedRange()
|
let selection = selectedRange()
|
||||||
let marker = task.attachment.marker as NSString
|
let marker = task.attachment.marker as NSString
|
||||||
|
|||||||
@@ -293,7 +293,8 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
|
|||||||
reset("- ab🦊cd")
|
reset("- ab🦊cd")
|
||||||
editor.setSelectedRange((editor.string as NSString).range(of: "🦊"))
|
editor.setSelectedRange((editor.string as NSString).range(of: "🦊"))
|
||||||
editor.keyDown(with: enter)
|
editor.keyDown(with: enter)
|
||||||
#expect(editor.string == "- ab\n- cd")
|
#expect(editor.string == "\u{fffc} ab\n\u{fffc} cd")
|
||||||
|
#expect(editor.markdownString == "- ab\n- cd")
|
||||||
#expect(editor.selectedRange() == NSRange(location: 7, length: 0))
|
#expect(editor.selectedRange() == NSRange(location: 7, length: 0))
|
||||||
reset("- [X] ab🦊cd")
|
reset("- [X] ab🦊cd")
|
||||||
editor.setSelectedRange((editor.string as NSString).range(of: "🦊"))
|
editor.setSelectedRange((editor.string as NSString).range(of: "🦊"))
|
||||||
@@ -303,25 +304,25 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
|
|||||||
#expect(editor.selectedRange() == NSRange(location: 7, length: 0))
|
#expect(editor.selectedRange() == NSRange(location: 7, length: 0))
|
||||||
reset("- parent\n - child\n - leaf")
|
reset("- parent\n - child\n - leaf")
|
||||||
editor.keyDown(with: enter)
|
editor.keyDown(with: enter)
|
||||||
#expect(editor.string == "- parent\n - child\n - leaf\n - ")
|
#expect(editor.string == "\u{fffc} parent\n \u{fffc} child\n \u{fffc} leaf\n \u{fffc} ")
|
||||||
editor.keyDown(with: enter)
|
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)
|
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)
|
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.
|
// 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 ancestors = (0..<80).map { String(repeating: " ", count: $0 * 2) + "- level \($0)" }.joined(separator: "\n")
|
||||||
let before = ancestors + "\n" + String(repeating: " ", count: 160) + "- leaf"
|
let before = ancestors + "\n" + String(repeating: " ", count: 160) + "- leaf"
|
||||||
reset(before)
|
reset(before)
|
||||||
editor.keyDown(with: enter)
|
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) {
|
for depth in stride(from: 79, through: 0, by: -1) {
|
||||||
editor.keyDown(with: enter)
|
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)
|
editor.keyDown(with: enter)
|
||||||
#expect(editor.string == before + "\n")
|
#expect(editor.markdownString == before + "\n")
|
||||||
reset("- [X] parent\n - [ ] ")
|
reset("- [X] parent\n - [ ] ")
|
||||||
editor.keyDown(with: enter)
|
editor.keyDown(with: enter)
|
||||||
editor.breakUndoCoalescing()
|
editor.breakUndoCoalescing()
|
||||||
@@ -336,11 +337,14 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
|
|||||||
reset("7. saved")
|
reset("7. saved")
|
||||||
editor.keyDown(with: enter)
|
editor.keyDown(with: enter)
|
||||||
editor.breakUndoCoalescing()
|
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()
|
editor.undoManager?.undo()
|
||||||
#expect(editor.string == "7. saved")
|
#expect(editor.string == "\u{fffc} saved")
|
||||||
|
#expect(editor.markdownString == "7. saved")
|
||||||
editor.undoManager?.redo()
|
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 restored = try NotesStore(directory: directory)
|
||||||
let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:)))
|
let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:)))
|
||||||
#expect(restored.notes.first { $0.id == id }?.contents.string == "7. saved\n8. ")
|
#expect(restored.notes.first { $0.id == id }?.contents.string == "7. saved\n8. ")
|
||||||
@@ -422,7 +426,7 @@ func taskCheckboxesToggleMarkdownWithUndoPersistenceAndListContinuation() throws
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func atomicTasksExpandAtDeletionBoundariesAndNormalizeLineJoinsInOneUndo() throws {
|
func atomicListMarkersExpandAtDeletionBoundariesAndNormalizeLineJoinsInOneUndo() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
let suite = "NotesTaskDeletion.\(UUID().uuidString)"
|
let suite = "NotesTaskDeletion.\(UUID().uuidString)"
|
||||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||||
@@ -457,16 +461,22 @@ func atomicTasksExpandAtDeletionBoundariesAndNormalizeLineJoinsInOneUndo() throw
|
|||||||
("- [ ] buy", false, " [ ] buy"),
|
("- [ ] buy", false, " [ ] buy"),
|
||||||
("\t+ [X]\t🦊", false, "\t [X]\t🦊"),
|
("\t+ [X]\t🦊", false, "\t [X]\t🦊"),
|
||||||
(" 12)\t[x] done", false, " 2)\t[x] done"),
|
(" 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 {
|
for (source, backward, expanded) in boundaries {
|
||||||
let rendered = reset(source)
|
let rendered = reset(source)
|
||||||
let token = (rendered as NSString).range(of: "\u{fffc}")
|
let token = (rendered as NSString).range(of: "\u{fffc}")
|
||||||
#expect(token.length == 1)
|
#expect(token.length == 1)
|
||||||
|
let marker = editor.markdownRange(for: token)
|
||||||
editor.setSelectedRange(NSRange(location: token.location + (backward ? 1 : 0), length: 0))
|
editor.setSelectedRange(NSRange(location: token.location + (backward ? 1 : 0), length: 0))
|
||||||
delete(backward: backward)
|
delete(backward: backward)
|
||||||
#expect(editor.string == expanded)
|
#expect(editor.string == expanded)
|
||||||
#expect(editor.markdownString == 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))
|
#expect(editor.selectedRange() == NSRange(location: caret, length: 0))
|
||||||
editor.undoManager?.undo()
|
editor.undoManager?.undo()
|
||||||
#expect(editor.string == rendered)
|
#expect(editor.string == rendered)
|
||||||
@@ -477,6 +487,8 @@ func atomicTasksExpandAtDeletionBoundariesAndNormalizeLineJoinsInOneUndo() throw
|
|||||||
#expect(editor.markdownString == expanded)
|
#expect(editor.markdownString == expanded)
|
||||||
}
|
}
|
||||||
let joins = [
|
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- [ ] task", "prose- [ ] task", "prose- [ ] task"),
|
||||||
("prose\n - [X] task", "prose - [X] task", "prose - [X] task"),
|
("prose\n - [X] task", "prose - [X] task", "prose - [X] task"),
|
||||||
("🦊\n\t12) [x]\tfinish", "🦊\t12) [x]\tfinish", "🦊\t12) [x]\tfinish"),
|
("🦊\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 source = NSMutableAttributedString(string: "\t12)\t[X]\t🦊 ")
|
||||||
let imageLocation = source.length
|
let imageLocation = source.length
|
||||||
source.append(NSAttributedString(attachment: NSTextAttachment(fileWrapper: imageFile)))
|
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 store = try NotesStore(directory: directory)
|
||||||
let id = try store.create()
|
let id = try store.create()
|
||||||
try store.update(id: id, contents: source)
|
try store.update(id: id, contents: source)
|
||||||
@@ -536,7 +548,7 @@ func atomicTasksCopySelectAndPersistMarkdownAlongsideUnicodeAndImages() throws {
|
|||||||
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||||
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
|
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
|
||||||
window.makeFirstResponder(editor)
|
window.makeFirstResponder(editor)
|
||||||
let 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.string == rendered)
|
||||||
#expect(editor.markdownString == source.string)
|
#expect(editor.markdownString == source.string)
|
||||||
let token = NSRange(location: 1, length: 1)
|
let token = NSRange(location: 1, length: 1)
|
||||||
@@ -568,7 +580,7 @@ func atomicTasksCopySelectAndPersistMarkdownAlongsideUnicodeAndImages() throws {
|
|||||||
editor.setSelectedRange(token)
|
editor.setSelectedRange(token)
|
||||||
editor.deleteBackward(nil)
|
editor.deleteBackward(nil)
|
||||||
editor.breakUndoCoalescing()
|
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: ""))
|
#expect(editor.markdownString == (source.string as NSString).replacingCharacters(in: marker, with: ""))
|
||||||
editor.undoManager?.undo()
|
editor.undoManager?.undo()
|
||||||
#expect(editor.string == rendered)
|
#expect(editor.string == rendered)
|
||||||
@@ -615,7 +627,10 @@ func taskTokensRequireTrailingWhitespaceAndAcceptEmptyBracketsWhileTyping() thro
|
|||||||
for character in marker {
|
for character in marker {
|
||||||
typed.append(character)
|
typed.append(character)
|
||||||
editor.insertText(String(character), replacementRange: editor.selectedRange())
|
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())
|
editor.insertText(" ", replacementRange: editor.selectedRange())
|
||||||
let indentation = String(marker.prefix { $0 == " " || $0 == "\t" })
|
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.insertText("- [] ", replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
|
||||||
editor.keyDown(with: enter)
|
editor.keyDown(with: enter)
|
||||||
#expect(editor.markdownString.isEmpty)
|
#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