fix: render additional spaces after list markers

This commit is contained in:
Valdemar Lorenzen
2026-10-19 13:40:17 +02:00
parent bbee699cc8
commit efabd3ae18
3 changed files with 76 additions and 18 deletions
+1 -1
View File
@@ -143,7 +143,7 @@ Edit Markdown directly: headings, bold, italic, lists, links, inline code, and f
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 `[ ]`.
List markers are atomic inline tokens, including nested bullets and numbers; task markers such as `- [ ]` and `- [x]` use native checkboxes. Left/Right crosses a marker and its hidden following spaces or tabs in one visible step, between the marker's start and the item body. Shift-Left/Right uses the same boundaries and preserves the selection anchor when extending or reversing direction. Visible indentation and body whitespace retain normal character stops. Selecting only a token copies only its original marker; a keyboard selection across the marker also includes its following separator. Click a checkbox to toggle completion without moving the selection. Backspace at the start of the body expands the original marker and deletes its final character, preserving the separator; forward Delete immediately before the token 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. List markers are atomic inline tokens, including nested bullets and numbers; task markers such as `- [ ]` and `- [x]` use native checkboxes. Left/Right crosses a marker and its single required separator (a space or tab) in one visible step, between the marker's start and the item body. Only that separator is hidden: additional spaces and tabs are visible body text with normal cursor stops. Shift-Left/Right uses the same boundaries and preserves the selection anchor when extending or reversing direction. Visible indentation and body whitespace retain normal character stops. Selecting only a token copies only its original marker; a keyboard selection across the marker also includes its required separator. Click a checkbox to toggle completion without moving the selection. Backspace at the start of the body expands the original marker and deletes its final character, preserving the separator; forward Delete immediately before the token 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.
Unordered markers display as `•`; numbered markers display their original number and delimiter, right-aligned before the item text. The stored and copied Markdown retains the original bullet spelling. Unordered markers display as `•`; numbered markers display their original number and delimiter, right-aligned before the item text. The stored and copied Markdown retains the original bullet spelling.
+5 -4
View File
@@ -522,15 +522,16 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
style.tabStops = [] style.tabStops = []
style.defaultTabInterval = 4 * space style.defaultTabInterval = 4 * space
storage.addAttribute(.paragraphStyle, value: style, range: paragraph) storage.addAttribute(.paragraphStyle, value: style, range: paragraph)
var end = NSMaxRange(task.range) let separator = NSMaxRange(task.range)
while end < NSMaxRange(paragraph) && (source.character(at: end) == 32 || source.character(at: end) == 9) { end += 1 } if separator < NSMaxRange(paragraph), source.character(at: separator) == 32 || source.character(at: separator) == 9 {
storage.addAttribute(listWhitespace, value: true, storage.addAttribute(listWhitespace, value: true,
range: NSRange(location: NSMaxRange(task.range), length: end - NSMaxRange(task.range))) range: NSRange(location: separator, length: 1))
}
(task.attachment.attachmentCell as? ListAttachmentCell)?.columnWidth = column (task.attachment.attachmentCell as? ListAttachmentCell)?.columnWidth = column
task.attachment.bounds.size.width = column task.attachment.bounds.size.width = column
} }
storage.endEditing() storage.endEditing()
// Whitespace remains editable source, but occupies no part of the native marker column. // Only the required separator is hidden; additional whitespace is visible item-body text.
layoutManager.invalidateGlyphs(forCharacterRange: range, changeInLength: 0, actualCharacterRange: nil) layoutManager.invalidateGlyphs(forCharacterRange: range, changeInLength: 0, actualCharacterRange: nil)
} }
+66 -9
View File
@@ -537,11 +537,11 @@ func listNavigationSkipsHiddenSeparatorsAndKeepsSelectionAnchorsAndDeletionUndo(
let body = "🦊e\u{301} z" let body = "🦊e\u{301} z"
// Prefix source, rendered prefix, marker, separator, backward/forward expansions. // Prefix source, rendered prefix, marker, separator, backward/forward expansions.
let fixtures = [ let fixtures = [
("", "", "-", " \t", "", ""), ("", "", "-", " ", "", ""),
("\t", "\t", "+", "\t ", "", ""), ("\t", "\t", "+", "\t", "", ""),
(" ", " ", "009)", "\t \t", "009", "09)"), (" ", " ", "009)", "\t", "009", "09)"),
("- parent\n ", "\u{fffc} parent\n ", "- [X]", " \t ", "- [X", " [X]"), ("- parent\n ", "\u{fffc} parent\n ", "- [X]", " ", "- [X", " [X]"),
("\t", "\t", "12)\t[]", "\t \t", "12)\t[", "2)\t[]"), ("\t", "\t", "12)\t[]", "\t", "12)\t[", "2)\t[]"),
] ]
for (prefix, renderedPrefix, marker, separator, backwardExpansion, forwardExpansion) in fixtures { for (prefix, renderedPrefix, marker, separator, backwardExpansion, forwardExpansion) in fixtures {
let source = prefix + marker + separator + body let source = prefix + marker + separator + body
@@ -1005,20 +1005,22 @@ func listBodyInsertionUsesVisiblePositionForTypingPasteAndReplacementRanges() th
editor.breakUndoCoalescing() editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions() editor.undoManager?.removeAllActions()
editor.insertText("🦊", replacementRange: explicitRange ? insertion : NSRange(location: NSNotFound, length: 0)) editor.insertText("🦊", replacementRange: explicitRange ? insertion : NSRange(location: NSNotFound, length: 0))
#expect(editor.markdownString == marker + separator + "🦊tail") let position = max(1, offset)
#expect(editor.markdownString == marker + String(separator.prefix(position)) + "🦊" + String(separator.dropFirst(position)) + "tail")
#expect((editor.string as NSString).range(of: "\u{fffc}").location != NSNotFound) #expect((editor.string as NSString).range(of: "\u{fffc}").location != NSNotFound)
#expect(editor.selectedRange().location == NSMaxRange(token) + separator.utf16.count + 2) #expect(editor.selectedRange().location == NSMaxRange(token) + position + 2)
editor.breakUndoCoalescing() editor.breakUndoCoalescing()
editor.undoManager?.undo() editor.undoManager?.undo()
#expect(editor.markdownString == original) #expect(editor.markdownString == original)
editor.undoManager?.redo() editor.undoManager?.redo()
#expect(editor.markdownString == marker + separator + "🦊tail") #expect(editor.markdownString == marker + String(separator.prefix(position)) + "🦊" + String(separator.dropFirst(position)) + "tail")
} }
editor.setMarkdownContents(NSAttributedString(string: original)) editor.setMarkdownContents(NSAttributedString(string: original))
let token = (editor.string as NSString).range(of: "\u{fffc}") let token = (editor.string as NSString).range(of: "\u{fffc}")
editor.setSelectedRange(NSRange(location: NSMaxRange(token) + offset, length: 0)) editor.setSelectedRange(NSRange(location: NSMaxRange(token) + offset, length: 0))
#expect(editor.readSelection(from: board)) #expect(editor.readSelection(from: board))
#expect(editor.markdownString == marker + separator + "pastedtail") let position = max(1, offset)
#expect(editor.markdownString == marker + String(separator.prefix(position)) + "pasted" + String(separator.dropFirst(position)) + "tail")
} }
editor.setMarkdownContents(NSAttributedString(string: "")) editor.setMarkdownContents(NSAttributedString(string: ""))
editor.setSelectedRange(NSRange(location: 0, length: 0)) editor.setSelectedRange(NSRange(location: 0, length: 0))
@@ -1038,3 +1040,58 @@ func listBodyInsertionUsesVisiblePositionForTypingPasteAndReplacementRanges() th
editor.insertText("!", replacementRange: separator) editor.insertText("!", replacementRange: separator)
#expect(editor.markdownString == "-!abXcd") #expect(editor.markdownString == "-!abXcd")
} }
@Test @MainActor
func extraListBodyWhitespaceIsVisibleAndHasNormalCursorStops() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 520, height: 200))
editor.isRichText = true
editor.allowsUndo = true
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
window.contentView = editor
defer { window.orderOut(nil) }
window.makeFirstResponder(editor)
let layout = try #require(editor.layoutManager)
let container = try #require(editor.textContainer)
func bodyX() -> CGFloat {
layout.ensureLayout(for: container)
let range = (editor.string as NSString).range(of: "tail")
let glyph = layout.glyphIndexForCharacter(at: range.location)
return layout.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil).minX + layout.location(forGlyphAt: glyph).x
}
for marker in ["-", "+", "*", "12.", "99)", "- []", "- [x]", " - [ ]"] {
for whitespace in [" ", " ", "\t", "\t "] {
let source = marker + " tail"
editor.setMarkdownContents(NSAttributedString(string: source))
let token = (editor.string as NSString).range(of: "\u{fffc}")
let start = NSMaxRange(token) + 1
let originalX = bodyX()
editor.setSelectedRange(NSRange(location: start, length: 0))
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
editor.insertText(whitespace, replacementRange: editor.selectedRange())
#expect(editor.markdownString == marker + " " + whitespace + "tail")
#expect(bodyX() > originalX, "Additional body whitespace must advance the rendered text")
#expect(editor.selectedRange().location == start + whitespace.utf16.count)
editor.setSelectedRange(NSRange(location: token.location, length: 0))
editor.moveRight(nil)
#expect(editor.selectedRange().location == start)
for index in 1...whitespace.utf16.count {
editor.moveRight(nil)
#expect(editor.selectedRange().location == start + index)
}
for index in stride(from: whitespace.utf16.count - 1, through: 0, by: -1) {
editor.moveLeft(nil)
#expect(editor.selectedRange().location == start + index)
}
editor.moveLeft(nil)
#expect(editor.selectedRange().location == token.location)
editor.breakUndoCoalescing()
editor.undoManager?.undo()
#expect(editor.markdownString == source)
#expect(abs(bodyX() - originalX) < 0.5)
editor.undoManager?.redo()
#expect(bodyX() > originalX)
}
}
}