From bbee699cc8c48731465fb6c419c12d70fb3628d5 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Mon, 19 Oct 2026 13:34:18 +0200 Subject: [PATCH] fix: insert list body text after hidden separators --- README.md | 2 + Sources/Conjure/NotesController.swift | 2 +- Sources/Conjure/NotesTextView.swift | 13 +++- Tests/ConjureKitTests/NotesEditingTests.swift | 59 +++++++++++++++++++ 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a9d6500..f253951 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,8 @@ List markers are atomic inline tokens, including nested bullets and numbers; tas 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. +Typing, pasting, and Return at the visible start of an item use the position after its preserved Markdown separator. Continuing to type after a marker converts therefore keeps the item rendered, rather than inserting characters into the marker syntax. + Fenced code uses [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) (Highlight.js) for language-aware token colors. Add a language after the opening backticks or tildes, for example `py`, `python`, `swift`, `js`, or `json`. Unlabeled fences use automatic detection; unknown languages remain plain monospace. Highlighting runs entirely offline. Notes autosave locally as RTFD packages, including embedded images, under `~/Library/Application Support/Conjure/Notes`. No account, network access, or cloud sync is involved. Save failures appear below the editor and prevent switching, closing, or quitting until saving succeeds, preserving the unsaved buffer. The Back chevron restores the launcher search when no popup is open. Command-W dismisses Conjure, including any open popup; leaving Notes or switching destinations removes its popup completely. diff --git a/Sources/Conjure/NotesController.swift b/Sources/Conjure/NotesController.swift index 26ebeeb..a33639d 100644 --- a/Sources/Conjure/NotesController.swift +++ b/Sources/Conjure/NotesController.swift @@ -145,7 +145,7 @@ final class NotesController: NSViewController, NSTextViewDelegate { guard textView === editor, editor.isEditable, commandSelector == #selector(NSResponder.insertNewline(_:)) else { return false } let source = editor.markdownString as NSString - let selection = editor.markdownRange(for: editor.selectedRange()) + 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)) diff --git a/Sources/Conjure/NotesTextView.swift b/Sources/Conjure/NotesTextView.swift index f50e59c..788f4ee 100644 --- a/Sources/Conjure/NotesTextView.swift +++ b/Sources/Conjure/NotesTextView.swift @@ -202,6 +202,12 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate { replaceSource(NSAttributedString(string: text, attributes: textTypingAttributes), range: range, semantic: true) } + func markdownInsertionRange(for rendered: NSRange) -> NSRange { + guard rendered.length == 0 else { return markdownRange(for: rendered) } + let body = listWhitespaceBoundary(from: rendered.location, forward: true) + return markdownRange(for: NSRange(location: body, length: 0)) + } + override func insertText(_ insertString: Any, replacementRange: NSRange) { let replacement: NSAttributedString if let attributed = insertString as? NSAttributedString { @@ -210,7 +216,7 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate { replacement = NSAttributedString(string: text, attributes: textTypingAttributes) } else { return } let range = replacementRange.location == NSNotFound ? selectedRange() : replacementRange - replaceSource(replacement, range: markdownRange(for: range), semantic: false) + replaceSource(replacement, range: markdownInsertionRange(for: range), semantic: false) } private func replaceSource(_ replacement: NSAttributedString, range: NSRange, semantic: Bool) { @@ -252,7 +258,8 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate { if semantic { breakUndoCoalescing() } } refreshTasks() - setSelectedRange(renderedRange(for: NSRange(location: range.location + replacement.length, length: 0))) + let caret = renderedRange(for: NSRange(location: range.location + replacement.length, length: 0)) + setSelectedRange(NSRange(location: listWhitespaceBoundary(from: caret.location, forward: true), length: 0)) } override func deleteBackward(_ sender: Any?) { @@ -434,7 +441,7 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate { reader.importsGraphics = importsGraphics guard reader.readSelection(from: pboard, type: type) else { return false } replaceSource(Self.serialize(reader.attributedString(), keepingMetadata: true), - range: markdownRange(for: selectedRange()), semantic: true) + range: markdownInsertionRange(for: selectedRange()), semantic: true) return true } diff --git a/Tests/ConjureKitTests/NotesEditingTests.swift b/Tests/ConjureKitTests/NotesEditingTests.swift index 576e44a..f0cad01 100644 --- a/Tests/ConjureKitTests/NotesEditingTests.swift +++ b/Tests/ConjureKitTests/NotesEditingTests.swift @@ -979,3 +979,62 @@ func renderedListMarkersPaintBulletsAndNumbersInsideTheirGlyphFrames() throws { #expect(editor.markdownString == source) } } + +@Test @MainActor +func listBodyInsertionUsesVisiblePositionForTypingPasteAndReplacementRanges() 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 board = NSPasteboard.withUniqueName() + defer { board.releaseGlobally() } + board.setString("pasted", forType: .string) + for marker in ["-", "*", "+", "1.", "123)", "- []", "- [ ]", "- [X]", "\t12) []"] { + let separator = " \t " + let original = marker + separator + "tail" + for offset in 0...separator.utf16.count { + for explicitRange in [false, true] { + editor.setMarkdownContents(NSAttributedString(string: original)) + let token = (editor.string as NSString).range(of: "\u{fffc}") + let insertion = NSRange(location: NSMaxRange(token) + offset, length: 0) + editor.setSelectedRange(insertion) + editor.breakUndoCoalescing() + editor.undoManager?.removeAllActions() + editor.insertText("🦊", replacementRange: explicitRange ? insertion : NSRange(location: NSNotFound, length: 0)) + #expect(editor.markdownString == marker + separator + "🦊tail") + #expect((editor.string as NSString).range(of: "\u{fffc}").location != NSNotFound) + #expect(editor.selectedRange().location == NSMaxRange(token) + separator.utf16.count + 2) + editor.breakUndoCoalescing() + editor.undoManager?.undo() + #expect(editor.markdownString == original) + editor.undoManager?.redo() + #expect(editor.markdownString == marker + separator + "🦊tail") + } + editor.setMarkdownContents(NSAttributedString(string: original)) + let token = (editor.string as NSString).range(of: "\u{fffc}") + editor.setSelectedRange(NSRange(location: NSMaxRange(token) + offset, length: 0)) + #expect(editor.readSelection(from: board)) + #expect(editor.markdownString == marker + separator + "pastedtail") + } + editor.setMarkdownContents(NSAttributedString(string: "")) + editor.setSelectedRange(NSRange(location: 0, length: 0)) + for character in marker + " " { + editor.insertText(String(character), replacementRange: editor.selectedRange()) + } + let token = (editor.string as NSString).range(of: "\u{fffc}") + #expect(editor.selectedRange().location == NSMaxRange(token) + 1) + editor.insertText("text", replacementRange: NSRange(location: NSNotFound, length: 0)) + #expect(editor.markdownString == marker + " text") + } + editor.setMarkdownContents(NSAttributedString(string: "- ab🦊cd")) + let emoji = (editor.string as NSString).range(of: "🦊") + editor.insertText("X", replacementRange: emoji) + #expect(editor.markdownString == "- abXcd") + let separator = NSRange(location: 1, length: 3) + editor.insertText("!", replacementRange: separator) + #expect(editor.markdownString == "-!abXcd") +}