fix: skip invisible list separator caret stops

This commit is contained in:
Valdemar Lorenzen
2026-10-19 13:24:01 +02:00
parent e14b195856
commit ac28b6dd61
3 changed files with 235 additions and 20 deletions
+109 -17
View File
@@ -47,11 +47,13 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
)
private var tasks: [(range: NSRange, attachment: ListAttachment)] = []
private var checkboxes: [NSButton] = []
private var horizontalSelection: (anchor: Int, head: Int)?
var markdownContents: NSAttributedString { Self.serialize(attributedString()) }
var markdownString: String { markdownContents.string }
func setMarkdownContents(_ contents: NSAttributedString) {
horizontalSelection = nil
textStorage?.setAttributedString(Self.render(contents))
refreshTasks()
}
@@ -255,7 +257,8 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
override func deleteBackward(_ sender: Any?) {
let selection = selectedRange()
if selection.length == 0, let task = tasks.first(where: { NSMaxRange($0.range) == selection.location }) {
let boundary = listWhitespaceBoundary(from: selection.location, forward: false)
if selection.length == 0, let task = tasks.first(where: { NSMaxRange($0.range) == boundary }) {
expand(task, backwards: true)
} else if selection.length > 0 {
replaceMarkdown("", range: markdownRange(for: selection))
@@ -287,35 +290,123 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
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 {
// Only separator glyphs are null; indentation and body whitespace remain native stops.
private func listWhitespaceBoundary(from position: Int, forward: Bool) -> Int {
guard let storage = textStorage else { return position }
let index = forward ? position : position - 1
guard index >= 0, index < storage.length else { return position }
var range = NSRange()
guard storage.attribute(listWhitespace, at: index, effectiveRange: &range) as? Bool == true else {
return position
}
return forward ? NSMaxRange(range) : range.location
}
private func visualListBoundary(from position: Int, forward: Bool) -> Int? {
let boundary = listWhitespaceBoundary(from: position, forward: forward)
guard let task = tasks.first(where: {
forward ? $0.range.location == boundary : NSMaxRange($0.range) == boundary
}) else { return nil }
return forward ? listWhitespaceBoundary(from: NSMaxRange(task.range), forward: true) : task.range.location
}
override func setSelectedRange(_ charRange: NSRange) {
horizontalSelection = nil
super.setSelectedRange(charRange)
}
override func setSelectedRange(_ charRange: NSRange, affinity: NSSelectionAffinity, stillSelecting: Bool) {
horizontalSelection = nil
super.setSelectedRange(charRange, affinity: affinity, stillSelecting: stillSelecting)
}
override func setSelectedRanges(_ ranges: [NSValue], affinity: NSSelectionAffinity, stillSelecting: Bool) {
horizontalSelection = nil
super.setSelectedRanges(ranges, affinity: affinity, stillSelecting: stillSelecting)
}
private func moveNatively(forward: Bool, selecting: Bool, sender: Any?) {
if selecting {
if forward { super.moveRightAndModifySelection(sender) }
else { super.moveLeftAndModifySelection(sender) }
} else {
if forward { super.moveRight(sender) }
else { super.moveLeft(sender) }
}
}
private func moveHorizontally(forward: Bool, selecting: Bool, sender: Any?) {
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
let tracked = selecting ? horizontalSelection : nil
if !selecting { horizontalSelection = nil }
let anchor: Int
let head: Int
var nativeHead: Int?
if let tracked {
anchor = tracked.anchor
head = tracked.head
} else if selecting, selection.length > 0 {
// Let AppKit identify the active end of a mouse or otherwise native selection.
moveNatively(forward: forward, selecting: true, sender: sender)
let moved = selectedRange()
guard moved != selection else { return }
anchor = moved.location == selection.location ? selection.location : NSMaxRange(selection)
head = anchor == selection.location ? NSMaxRange(selection) : selection.location
nativeHead = moved.location == anchor ? NSMaxRange(moved) : moved.location
} else {
anchor = selection.location
head = selection.location
}
if (selecting || selection.length == 0),
let target = visualListBoundary(from: head, forward: forward) {
if selecting { setHorizontalSelection(anchor: anchor, head: target) }
else { setSelectedRange(NSRange(location: target, length: 0)) }
return
}
// Native movement supplies composed-character, bidi, and line-boundary behavior.
// Starting inside a null run already means "after the marker", so advance into the body.
let origin = listWhitespaceBoundary(from: head, forward: true)
if let nativeHead, origin == head {
let target = listWhitespaceBoundary(from: nativeHead, forward: true)
if target != nativeHead { setHorizontalSelection(anchor: anchor, head: target) }
return
}
if tracked != nil || nativeHead != nil || (selection.length == 0 && origin != head) {
setSelectedRange(NSRange(location: origin, length: 0))
}
moveNatively(forward: forward, selecting: selecting, sender: sender)
let moved = selectedRange()
let newHead = selecting && moved.location == origin ? NSMaxRange(moved) : moved.location
let target = listWhitespaceBoundary(from: newHead, forward: true)
if selecting {
if tracked != nil || nativeHead != nil || target != newHead || origin != head {
setHorizontalSelection(anchor: anchor, head: target)
}
} else if target != newHead { setSelectedRange(NSRange(location: target, length: 0)) }
}
private func setHorizontalSelection(anchor: Int, head: Int) {
setSelectedRange(NSRange(location: min(anchor, head), length: abs(head - anchor)),
affinity: selectionAffinity, stillSelecting: false)
horizontalSelection = (anchor, head)
}
override func moveRight(_ sender: Any?) {
if !moveAcrossListPrefix(forward: true, selecting: false) { super.moveRight(sender) }
moveHorizontally(forward: true, selecting: false, sender: sender)
}
override func moveLeft(_ sender: Any?) {
if !moveAcrossListPrefix(forward: false, selecting: false) { super.moveLeft(sender) }
moveHorizontally(forward: false, selecting: false, sender: sender)
}
override func moveRightAndModifySelection(_ sender: Any?) {
if !moveAcrossListPrefix(forward: true, selecting: true) { super.moveRightAndModifySelection(sender) }
moveHorizontally(forward: true, selecting: true, sender: sender)
}
override func moveLeftAndModifySelection(_ sender: Any?) {
if !moveAcrossListPrefix(forward: false, selecting: true) { super.moveLeftAndModifySelection(sender) }
moveHorizontally(forward: false, selecting: true, sender: sender)
}
private func markdownSelectionWriter() -> NSTextView {
@@ -354,6 +445,7 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
}
override func didChangeText() {
horizontalSelection = nil
refreshTasks()
super.didChangeText()
}