From ac28b6dd6102d927644ae96e639c343ea999ea3e Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Mon, 19 Oct 2026 13:24:01 +0200 Subject: [PATCH] fix: skip invisible list separator caret stops --- README.md | 2 +- Sources/Conjure/NotesTextView.swift | 126 ++++++++++++++--- Tests/ConjureKitTests/NotesEditingTests.swift | 127 +++++++++++++++++- 3 files changed, 235 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index aef55e4..a9d6500 100644 --- a/README.md +++ b/README.md @@ -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 `[ ]`. -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. +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. 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. diff --git a/Sources/Conjure/NotesTextView.swift b/Sources/Conjure/NotesTextView.swift index 51f20aa..f50e59c 100644 --- a/Sources/Conjure/NotesTextView.swift +++ b/Sources/Conjure/NotesTextView.swift @@ -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() } diff --git a/Tests/ConjureKitTests/NotesEditingTests.swift b/Tests/ConjureKitTests/NotesEditingTests.swift index f78bfd2..576e44a 100644 --- a/Tests/ConjureKitTests/NotesEditingTests.swift +++ b/Tests/ConjureKitTests/NotesEditingTests.swift @@ -514,6 +514,129 @@ func atomicListMarkersExpandAtDeletionBoundariesAndNormalizeLineJoinsInOneUndo() } } +@Test @MainActor +func listNavigationSkipsHiddenSeparatorsAndKeepsSelectionAnchorsAndDeletionUndo() throws { + _ = NSApplication.shared + let suite = "NotesVisualCaret.\(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: 750, height: 390), + styleMask: .borderless, backing: .buffered, defer: false) + window.contentViewController = controller + 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) + window.makeFirstResponder(editor) + let board = NSPasteboard.withUniqueName() + defer { board.releaseGlobally() } + let body = "🦊e\u{301} z" + // Prefix source, rendered prefix, marker, separator, backward/forward expansions. + let fixtures = [ + ("", "", "-", " \t", "", ""), + ("\t", "\t", "+", "\t ", "", ""), + (" ", " ", "009)", "\t \t", "009", "09)"), + ("- parent\n ", "\u{fffc} parent\n ", "- [X]", " \t ", "- [X", " [X]"), + ("\t", "\t", "12)\t[]", "\t \t", "12)\t[", "2)\t[]"), + ] + for (prefix, renderedPrefix, marker, separator, backwardExpansion, forwardExpansion) in fixtures { + let source = prefix + marker + separator + body + editor.insertText(source, replacementRange: NSRange(location: 0, length: editor.string.utf16.count)) + let rendered = renderedPrefix + "\u{fffc}" + separator + body + #expect(editor.string == rendered) + let before = renderedPrefix.utf16.count + let start = before + 1 + separator.utf16.count + func caret(_ index: Int) { editor.setSelectedRange(NSRange(location: index, length: 0)) } + func expectSelection(_ location: Int, _ length: Int = 0) { + #expect(editor.selectedRange() == NSRange(location: location, length: length)) + } + caret(before) + editor.moveRight(nil) + expectSelection(start) + editor.moveRight(nil) + expectSelection(start + 2) + editor.moveRight(nil) + expectSelection(start + 4) + editor.moveRight(nil) + expectSelection(start + 5) // Visible body spaces still have individual stops. + editor.moveLeft(nil) + expectSelection(start + 4) + editor.moveLeft(nil) + expectSelection(start + 2) + editor.moveLeft(nil) + expectSelection(start) + editor.moveLeft(nil) + expectSelection(before) + if before > 0 { + editor.moveLeft(nil) + expectSelection(before - 1) + editor.moveRight(nil) + expectSelection(before) + } + editor.moveRightAndModifySelection(nil) + expectSelection(before, start - before) + #expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes)) + #expect(board.string(forType: .string) == marker + separator) + editor.moveRightAndModifySelection(nil) + expectSelection(before, start + 2 - before) + editor.moveRightAndModifySelection(nil) + expectSelection(before, start + 4 - before) + editor.moveLeftAndModifySelection(nil) + expectSelection(before, start + 2 - before) + editor.moveLeftAndModifySelection(nil) + expectSelection(before, start - before) + editor.moveLeftAndModifySelection(nil) + expectSelection(before) + if before > 0 { + editor.moveLeftAndModifySelection(nil) + expectSelection(before - 1, 1) + editor.moveRightAndModifySelection(nil) + expectSelection(before) + } + caret(start) + editor.moveLeftAndModifySelection(nil) + expectSelection(before, start - before) + editor.moveRightAndModifySelection(nil) + expectSelection(start) + editor.moveRightAndModifySelection(nil) + expectSelection(start, 2) + editor.moveLeftAndModifySelection(nil) + expectSelection(start) + editor.moveLeftAndModifySelection(nil) + expectSelection(before, start - before) + editor.moveRightAndModifySelection(nil) + expectSelection(start) + editor.setSelectedRange(NSRange(location: 0, length: rendered.utf16.count)) + board.clearContents() + #expect(editor.writeSelection(to: board, types: editor.writablePasteboardTypes)) + #expect(board.string(forType: .string) == source) + #expect(controller.save()) + let persisted = try NotesStore(directory: directory) + #expect(persisted.notes.contains { $0.contents.string == source }) + for backward in [true, false] { + editor.setMarkdownContents(NSAttributedString(string: source)) + editor.breakUndoCoalescing() + editor.undoManager?.removeAllActions() + caret(before) + if backward { editor.moveRight(nil); editor.deleteBackward(nil) } + else { editor.deleteForward(nil) } + editor.breakUndoCoalescing() + let expansion = backward ? backwardExpansion : forwardExpansion + #expect(editor.markdownString == prefix + expansion + separator + body) + #expect(editor.selectedRange() == NSRange( + location: before + (backward ? expansion.utf16.count : 0), length: 0)) + editor.undoManager?.undo() + #expect(editor.string == rendered) + #expect(editor.markdownString == source) + #expect(editor.undoManager?.canUndo == false) + } + } +} + @Test @MainActor func atomicTasksCopySelectAndPersistMarkdownAlongsideUnicodeAndImages() throws { _ = NSApplication.shared @@ -558,11 +681,11 @@ func atomicTasksCopySelectAndPersistMarkdownAlongsideUnicodeAndImages() throws { #expect(editor.renderedRange(for: (source.string as NSString).range(of: "🦊")) == (rendered as NSString).range(of: "🦊")) editor.setSelectedRange(NSRange(location: token.location, length: 0)) editor.moveRight(nil) - #expect(editor.selectedRange() == NSRange(location: NSMaxRange(token), length: 0)) + #expect(editor.selectedRange() == NSRange(location: NSMaxRange(token) + 1, length: 0)) editor.moveLeft(nil) #expect(editor.selectedRange() == NSRange(location: token.location, length: 0)) editor.moveRightAndModifySelection(nil) - #expect(editor.selectedRange() == token) + #expect(editor.selectedRange() == NSRange(location: token.location, length: 2)) let board = NSPasteboard.withUniqueName() defer { board.releaseGlobally() } editor.setSelectedRange(token)