fix: restore empty list exit and keyboard indentation

This commit is contained in:
Valdemar Lorenzen
2026-10-19 15:06:47 +02:00
parent 21619c5420
commit 1c3ecb232b
4 changed files with 221 additions and 21 deletions
+66 -19
View File
@@ -124,10 +124,26 @@ final class NotesController: NSViewController, NSTextViewDelegate {
pattern: "^([ \\t]*)([-+*]|([0-9]+)([.)]))([ \\t]+|$)(?:\\[([ xX]?)\\]([ \\t]+|$))?"
)
private static func listMatch(in line: NSString) -> NSTextCheckingResult? {
guard let item = listItem.firstMatch(in: line as String, range: NSRange(location: 0, length: line.length)) else { return nil }
let marker = line.substring(with: item.range(at: 2))
// Spaced thematic breaks are not list items or list ancestors.
if marker == "-" || marker == "*" {
let characters = (line as String).lazy.filter { !$0.isWhitespace }
let bullet = marker.first!
if characters.count >= 3, characters.allSatisfy({ $0 == bullet }) { return nil }
}
return item
}
private static func indentationWidth(_ indentation: String) -> Int {
indentation.reduce(0) { $1 == "\t" ? $0 + 4 - $0 % 4 : $0 + 1 }
}
private static func outdentLength(_ indentation: String) -> Int {
indentation.hasPrefix("\t") ? 1 : indentation.prefix(2).prefix { $0 == " " }.count
}
private static func continuedPrefix(_ item: NSTextCheckingResult, in line: NSString) -> String? {
var marker = line.substring(with: item.range(at: 2))
if item.range(at: 3).location != NSNotFound {
@@ -141,38 +157,69 @@ final class NotesController: NSViewController, NSTextViewDelegate {
return prefix
}
private func setSelectionWithUndo(_ selection: NSRange) {
let previous = editor.selectedRange()
editor.undoManager?.registerUndo(withTarget: self) { $0.setSelectionWithUndo(previous) }
editor.setSelectedRange(selection)
}
private func replaceListMarkdown(_ replacement: String, range: NSRange, selection: NSRange) {
editor.undoManager?.beginUndoGrouping()
// Native text undo restores its diff boundary, not the user's selection.
setSelectionWithUndo(editor.selectedRange())
editor.replaceMarkdown(replacement, range: range)
setSelectionWithUndo(editor.renderedRange(for: selection))
editor.undoManager?.endUndoGrouping()
}
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
guard textView === editor, editor.isEditable,
commandSelector == #selector(NSResponder.insertNewline(_:)) else { return false }
let newline = commandSelector == #selector(NSResponder.insertNewline(_:))
let indenting = commandSelector == #selector(NSResponder.insertTab(_:))
let outdenting = commandSelector == #selector(NSResponder.insertBacktab(_:))
guard textView === editor, editor.isEditable, newline || indenting || outdenting else { return false }
let source = editor.markdownString as NSString
let selection = editor.markdownInsertionRange(for: editor.selectedRange())
var start = 0, end = 0
source.getLineStart(&start, end: nil, contentsEnd: &end,
for: NSRange(location: selection.location, length: 0))
let line = source.substring(with: NSRange(location: start, length: end - start)) as NSString
guard let item = Self.listItem.firstMatch(in: line as String, range: NSRange(location: 0, length: line.length)),
selection.location >= start + item.range.length, NSMaxRange(selection) <= end,
!MarkdownHighlighting.codeBlocks(in: source as String).contains(where: {
selection.location >= $0.body.location && selection.location <= NSMaxRange($0.body)
}) else { return false }
guard let item = Self.listMatch(in: line), NSMaxRange(selection) <= end else { return false }
let codeBlocks = MarkdownHighlighting.codeBlocks(in: source as String)
guard !codeBlocks.contains(where: {
NSIntersectionRange($0.range, NSRange(location: start, length: end - start)).length > 0
}) else { return false }
let indent = line.substring(with: item.range(at: 1))
let marker = line.substring(with: item.range(at: 2))
// Spaced thematic breaks are not list items.
if marker == "-" || marker == "*" {
let characters = (line as String).lazy.filter { !$0.isWhitespace }
let bullet = marker.first!
if characters.count >= 3, characters.allSatisfy({ $0 == bullet }) { return false }
if indenting {
let marker = editor.renderedRange(for:
NSRange(location: start + item.range(at: 2).location, length: 0)).location
// A literal marker still needs native Tab to complete its separator.
guard editor.textStorage?.attribute(.attachment, at: marker, effectiveRange: nil) is NSTextAttachment else { return false }
}
if indenting || outdenting {
let removed = outdenting ? Self.outdentLength(indent) : 0
guard indenting || removed > 0 else { return true }
let replacement = indenting ? " " : ""
let delta = replacement.utf16.count - removed
let selectedStart = max(start, selection.location + delta)
let selectedEnd = max(start, NSMaxRange(selection) + delta)
replaceListMarkdown(replacement, range: NSRange(location: start, length: removed),
selection: NSRange(location: selectedStart, length: selectedEnd - selectedStart))
return true
}
let markerEnd = item.range(at: 6).location == NSNotFound
? NSMaxRange(item.range(at: 2)) : NSMaxRange(item.range(at: 6)) + 1
guard selection.location >= start + markerEnd else { return false }
let body = line.substring(from: item.range.length)
if body.trimmingCharacters(in: .whitespaces).isEmpty {
var replacement = ""
var replacement = indent.isEmpty ? "" : line.substring(from: Self.outdentLength(indent))
var previous = start
let depth = Self.indentationWidth(indent)
while depth > 0, previous > 0 {
let range = source.lineRange(for: NSRange(location: previous - 1, length: 0))
previous = range.location
let ancestor = source.substring(with: range) as NSString
if let parent = Self.listItem.firstMatch(in: ancestor as String, range: NSRange(location: 0, length: ancestor.length)) {
if let parent = Self.listMatch(in: ancestor),
!codeBlocks.contains(where: { NSIntersectionRange($0.range, range).length > 0 }) {
let parentIndent = ancestor.substring(with: parent.range(at: 1))
if Self.indentationWidth(parentIndent) < depth {
replacement = Self.continuedPrefix(parent, in: ancestor) ?? ""
@@ -183,12 +230,12 @@ final class NotesController: NSViewController, NSTextViewDelegate {
if Self.indentationWidth(String(leading)) < depth { break }
}
}
editor.replaceMarkdown(replacement, range: NSRange(location: start, length: end - start))
editor.setSelectedRange(editor.renderedRange(for: NSRange(location: start + replacement.utf16.count, length: 0)))
replaceListMarkdown(replacement, range: NSRange(location: start, length: end - start),
selection: NSRange(location: start + replacement.utf16.count, length: 0))
} else {
guard let prefix = Self.continuedPrefix(item, in: line) else { return false }
editor.replaceMarkdown("\n" + prefix, range: selection)
editor.setSelectedRange(editor.renderedRange(for: NSRange(location: selection.location + 1 + prefix.utf16.count, length: 0)))
replaceListMarkdown("\n" + prefix, range: selection,
selection: NSRange(location: selection.location + 1 + prefix.utf16.count, length: 0))
}
return true
}