From 662200e68400918b51edbf887358b5f61d1819e3 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Mon, 19 Oct 2026 19:00:16 +0200 Subject: [PATCH] fix: renumber sibling sequences when list indentation changes --- README.md | 2 +- Sources/Conjure/NotesController.swift | 74 ++++++++++++++++++- Tests/ConjureKitTests/NotesEditingTests.swift | 32 ++++++++ 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ad9bd74..e81f3d7 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ The bottom strip applies Markdown headings, bold, italic, strikethrough, inline Edit Markdown directly: headings, bold, italic, lists, links, inline code, and fenced code blocks are highlighted while the source remains visible, except for list markers rendered as inline tokens. Bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and native checkbox markers share a marker column, so item text aligns at the same nesting depth even with long numbers; wrapped lines align with the item text, not the marker. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues lists at the same indentation, incrementing numbers and resetting task checkboxes. On an empty item, Return exits one nesting level; repeat to leave the list entirely. Nesting has no fixed depth limit. Prose and fenced code retain ordinary newlines. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note. -Tab anywhere on a bullet, numbered, or task item indents that line by two spaces, preserving the caret or selection within the line. Shift-Tab removes one leading tab or up to two spaces; at the root it does nothing and never removes the item. These commands affect only the current line, not nested children. Return on a whitespace-only item outdents to its actual parent, or removes one indent unit when there is no parent; only Return on an empty root item removes the marker. Repeated Return therefore exits even a standalone nested list one level at a time. +Tab anywhere on a bullet, numbered, or task item indents that line by two spaces, preserving the caret or selection within the line. Shift-Tab removes one leading tab or up to two spaces; at the root it does nothing and never removes the item. These commands change only the current line’s indentation, not nested children. Moving an item into or out of a numbered list updates the affected sibling sequences: a new nested sequence starts at 1, an existing sequence continues from its preceding sibling, and later siblings are renumbered in the saved and copied Markdown too. Existing standalone starting numbers and each marker’s delimiter are preserved. Indentation and numbering changes undo together. Return on a whitespace-only item outdents to its actual parent, or removes one indent unit when there is no parent; only Return on an empty root item removes the marker. Repeated Return therefore exits even a standalone nested list one level at a time. 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 `[ ]`. diff --git a/Sources/Conjure/NotesController.swift b/Sources/Conjure/NotesController.swift index 2eb8150..0a32533 100644 --- a/Sources/Conjure/NotesController.swift +++ b/Sources/Conjure/NotesController.swift @@ -345,9 +345,77 @@ final class NotesController: NSViewController, NSTextViewDelegate { 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), + var selectedStart = max(start, selection.location + delta) + var selectedEnd = max(start, NSMaxRange(selection) + delta) + func isListLine(_ range: NSRange) -> Bool { + Self.listMatch(in: source.substring(with: range) as NSString) != nil + && !codeBlocks.contains { NSIntersectionRange($0.range, range).length > 0 } + } + var block = source.lineRange(for: NSRange(location: start, length: 0)) + while block.location > 0 { + let previous = source.lineRange(for: NSRange(location: block.location - 1, length: 0)) + guard isListLine(previous) else { break } + block = NSUnionRange(previous, block) + } + while NSMaxRange(block) < source.length { + let next = source.lineRange(for: NSRange(location: NSMaxRange(block), length: 0)) + guard isListLine(next) else { break } + block = NSUnionRange(block, next) + } + let body = NSMutableAttributedString(attributedString: editor.markdownContents.attributedSubstring(from: block)) + body.replaceCharacters(in: NSRange(location: start - block.location, length: removed), with: replacement) + let changed = body.string as NSString + let oldDepth = Self.indentationWidth(indent) + let newDepth = Self.indentationWidth(replacement + (indent as NSString).substring(from: removed)) + var ancestors: [(depth: Int, number: UInt64?, delimiter: String)] = [] + var edits: [(range: NSRange, number: String)] = [] + var oldLevel = true, newLevel = true + var offset = 0 + while offset < changed.length { + let row = changed.lineRange(for: NSRange(location: offset, length: 0)) + let text = changed.substring(with: row) as NSString + guard let match = Self.listMatch(in: text) else { break } + let depth = Self.indentationWidth(text.substring(with: match.range(at: 1))) + if offset + block.location > start { + if depth < oldDepth { oldLevel = false } + if depth < newDepth { newLevel = false } + } + while let last = ancestors.last, last.depth > depth { ancestors.removeLast() } + let peer = ancestors.last?.depth == depth ? ancestors.removeLast() : nil + var number: UInt64? + var delimiter = "" + if match.range(at: 3).location != NSNotFound { + number = UInt64(text.substring(with: match.range(at: 3))) + delimiter = text.substring(with: match.range(at: 4)) + if offset + block.location >= start, + (oldLevel && depth == oldDepth) || (newLevel && depth == newDepth) { + if let peer, peer.delimiter == delimiter, let previous = peer.number, previous < UInt64.max { + number = previous + 1 + } else if !ancestors.isEmpty || offset + block.location > start { + number = 1 + } + if let number, UInt64(text.substring(with: match.range(at: 3))) != number { + let digits = match.range(at: 3) + edits.append((NSRange(location: offset + digits.location, length: digits.length), String(number))) + } + } + } + ancestors.append((depth, number, delimiter)) + offset = NSMaxRange(row) + } + for edit in edits.reversed() { + body.replaceCharacters(in: edit.range, with: edit.number) + let location = block.location + edit.range.location + let difference = edit.number.utf16.count - edit.range.length + func adjusted(_ position: Int) -> Int { + if position <= location { return position } + if position < location + edit.range.length { return location + min(position - location, edit.number.utf16.count) } + return position + difference + } + selectedStart = adjusted(selectedStart) + selectedEnd = adjusted(selectedEnd) + } + replaceMarkdown(body, range: block, selection: NSRange(location: selectedStart, length: selectedEnd - selectedStart)) return true } diff --git a/Tests/ConjureKitTests/NotesEditingTests.swift b/Tests/ConjureKitTests/NotesEditingTests.swift index d85f3c1..a68d31c 100644 --- a/Tests/ConjureKitTests/NotesEditingTests.swift +++ b/Tests/ConjureKitTests/NotesEditingTests.swift @@ -515,6 +515,38 @@ func listTabAndShiftTabPreserveMarkersSourceSelectionsUndoAndPersistence() throw editor.breakUndoCoalescing() editor.undoManager?.removeAllActions() } + for (before, after, shift) in [ + ("1. hello,\n2. world!", "1. hello,\n 1. world!", false), + ("1. parent\n2. world!\n3. tail", "1. parent\n 1. world!\n2. tail", false), + ("1. parent\n 1. child\n2. world!\n3. tail", "1. parent\n 1. child\n 2. world!\n2. tail", false), + ("1. parent\n 1. world!\n2. tail", "1. parent\n2. world!\n3. tail", true), + ("7) parent\n 1) child\n 2) world!\n8) tail", "7) parent\n 1) child\n8) world!\n9) tail", true), + ("1. parent\n - bullet\n2. world!", "1. parent\n - bullet\n 1. world!", false), + ("1. parent\n\t1. child\n 2. world!", "1. parent\n\t1. child\n 2. world!", false), + ("1. parent\n2. world!\n\n9. separate", "1. parent\n 1. world!\n\n9. separate", false), + ("9) parent\n 1) world!\n10) tail", "9) parent\n10) world!\n11) tail", true), + ("1. parent\n10. world!\n11. tail", "1. parent\n 1. world!\n2. tail", false), + ("1. parent\n2. world!\n3. tail\n 7. separate", "1. parent\n 1. world!\n2. tail\n 7. separate", false), + ("1. parent\n 1. world!\n 2. child\n2. tail", "1. parent\n2. world!\n 1. child\n3. tail", true), + ("1. parent\r\n2) [X]\tworld!\r\n3. tail", "1. parent\r\n 1) [X]\tworld!\r\n2. tail", false), + ] { + reset(before) + let selected = (editor.string as NSString).range(of: "world!") + editor.setSelectedRange(selected) + editor.keyDown(with: shift ? backtab : tab) + editor.breakUndoCoalescing() + #expect(editor.markdownString == after) + #expect((editor.string as NSString).substring(with: editor.selectedRange()) == "world!") + let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:))) + let saved = try NotesStore(directory: directory).notes.first { $0.id == id }?.contents.string + #expect(saved == after.replacingOccurrences(of: "\r\n", with: "\n")) + editor.undoManager?.undo() + #expect(editor.markdownString == before) + #expect(editor.selectedRange() == selected) + #expect(editor.undoManager?.canUndo == false) + editor.undoManager?.redo() + #expect(editor.markdownString == after) + } for marker in ["-", "*", "+", "009)", "12.", "- [X]", "2) []"] { let source = " " + marker + " \t🦊 café\n - child" reset(source)