fix: wait for typed separator before completing list markers

This commit is contained in:
Valdemar Lorenzen
2026-10-19 14:01:49 +02:00
parent efabd3ae18
commit 21619c5420
3 changed files with 74 additions and 2 deletions
+2
View File
@@ -143,6 +143,8 @@ 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 `[ ]`.
When typing a list marker before existing whitespace on an otherwise blank line, the marker stays literal until you explicitly type Space or Tab immediately after it. Existing whitespace does not complete the marker for you. Loading or pasting valid list Markdown still renders it directly.
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. 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.
+27 -2
View File
@@ -216,14 +216,39 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
replacement = NSAttributedString(string: text, attributes: textTypingAttributes) replacement = NSAttributedString(string: text, attributes: textTypingAttributes)
} else { return } } else { return }
let range = replacementRange.location == NSNotFound ? selectedRange() : replacementRange let range = replacementRange.location == NSNotFound ? selectedRange() : replacementRange
replaceSource(replacement, range: markdownInsertionRange(for: range), semantic: false) replaceSource(replacement, range: markdownInsertionRange(for: range), semantic: false,
typedInput: insertString is String)
} }
private func replaceSource(_ replacement: NSAttributedString, range: NSRange, semantic: Bool) { private func replaceSource(_ replacement: NSAttributedString, range: NSRange, semantic: Bool, typedInput: Bool = false) {
guard isEditable else { return } guard isEditable else { return }
let source = NSMutableAttributedString(attributedString: Self.serialize(attributedString(), keepingMetadata: true)) let source = NSMutableAttributedString(attributedString: Self.serialize(attributedString(), keepingMetadata: true))
guard range.location <= source.length, NSMaxRange(range) <= source.length else { return } guard range.location <= source.length, NSMaxRange(range) <= source.length else { return }
var existingWhitespace = false
if typedInput, range.length == 0, replacement.length > 0 {
var end = 0
(source.string as NSString).getLineStart(nil, end: nil, contentsEnd: &end,
for: NSRange(location: range.location, length: 0))
let tail = (source.string as NSString).substring(with: NSRange(location: range.location, length: max(0, end - range.location)))
existingWhitespace = !tail.isEmpty && tail.allSatisfy { $0 == " " || $0 == "\t" }
}
source.replaceCharacters(in: range, with: replacement) source.replaceCharacters(in: range, with: replacement)
if typedInput, range.length == 0, replacement.length > 0 {
let line = (source.string as NSString).lineRange(for: NSRange(location: range.location, length: 0))
if let match = Self.listPattern.firstMatch(in: source.string, range: line) {
var marker = match.range(at: 2)
if match.range(at: 4).location != NSNotFound {
marker.length = NSMaxRange(match.range(at: 4)) - marker.location
}
if range.location == NSMaxRange(marker),
replacement.string.allSatisfy({ $0 == " " || $0 == "\t" }) {
source.removeAttribute(literalTaskMarker, range: marker)
} else if existingWhitespace, range.location >= marker.location,
range.location + replacement.length == NSMaxRange(marker) {
source.addAttribute(literalTaskMarker, value: true, range: marker)
}
}
}
let rendered = Self.render(source) let rendered = Self.render(source)
let before = attributedString() let before = attributedString()
let old = before.string as NSString, new = rendered.string as NSString let old = before.string as NSString, new = rendered.string as NSString
@@ -1095,3 +1095,48 @@ func extraListBodyWhitespaceIsVisibleAndHasNormalCursorStops() throws {
} }
} }
} }
@Test @MainActor
func typingMarkersBeforeExistingWhitespaceWaitsForAnExplicitSeparator() 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)
for marker in ["-", "*", "+", "12.", "9)", "- []", "- [x]"] {
for completion in [" ", "\t"] {
let prefix = "prose\n \t"
let tail = " \t"
editor.setMarkdownContents(NSAttributedString(string: prefix + tail))
editor.setSelectedRange(NSRange(location: prefix.utf16.count, length: 0))
for character in marker {
editor.insertText(String(character), replacementRange: editor.selectedRange())
}
#expect(editor.string == prefix + marker + tail)
#expect(editor.markdownString == prefix + marker + tail)
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
editor.insertText(completion, replacementRange: editor.selectedRange())
#expect(editor.string == prefix + "\u{fffc}" + completion + tail)
#expect(editor.markdownString == prefix + marker + completion + tail)
editor.breakUndoCoalescing()
editor.undoManager?.undo()
#expect(editor.string == prefix + marker + tail)
editor.undoManager?.redo()
#expect(editor.string == prefix + "\u{fffc}" + completion + tail)
editor.insertText("text", replacementRange: editor.selectedRange())
#expect(editor.markdownString == prefix + marker + completion + "text" + tail)
// Existing Markdown still renders on loading or pasting, without a typing gate.
editor.setMarkdownContents(NSAttributedString(string: marker + tail))
#expect(editor.string == "\u{fffc}" + tail)
editor.setMarkdownContents(NSAttributedString(string: tail))
editor.setSelectedRange(NSRange(location: 0, length: 0))
editor.insertText(NSAttributedString(string: marker + " pasted"), replacementRange: editor.selectedRange())
#expect(editor.string == "\u{fffc} pasted" + tail)
#expect(editor.markdownString == marker + " pasted" + tail)
}
}
}