fix: restore empty list exit and keyboard indentation
This commit is contained in:
@@ -141,9 +141,11 @@ Actions opens the same searchable popup style used throughout Conjure, with **Br
|
||||
|
||||
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.
|
||||
|
||||
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 `[ ]`.
|
||||
|
||||
When typing a list marker before existing whitespace on an otherwise blank line, the marker stays literal until you explicitly type Space or Tab immediately after it. Existing whitespace does not complete the marker for you. Loading or pasting valid list Markdown still renders it directly.
|
||||
When typing a list marker before existing whitespace on an otherwise blank line, the marker stays literal until you explicitly type Space or Tab immediately after it. Existing whitespace does not complete the marker for you. Tab completes a still-literal marker; on an already rendered list item it indents the line. Loading or pasting valid list Markdown still renders it directly.
|
||||
|
||||
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 single required separator (a space or tab) in one visible step, between the marker's start and the item body. Only that separator is hidden: additional spaces and tabs are visible body text with normal cursor stops. 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 required 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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -208,6 +208,15 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
||||
return markdownRange(for: NSRange(location: body, length: 0))
|
||||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
if event.keyCode == 48,
|
||||
event.modifierFlags.intersection([.command, .control, .option, .shift]) == .shift,
|
||||
delegate?.textView?(self, doCommandBy: #selector(NSResponder.insertBacktab(_:))) == true {
|
||||
return
|
||||
}
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
|
||||
override func insertText(_ insertString: Any, replacementRange: NSRange) {
|
||||
let replacement: NSAttributedString
|
||||
if let attributed = insertString as? NSAttributedString {
|
||||
|
||||
@@ -275,7 +275,9 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
|
||||
("- parent\n\t+ child\n\t\t3) ", "- parent\n\t+ child\n\t+ "),
|
||||
("- first\n - child\n - ", "- first\n - child\n- "),
|
||||
("- parent\n continuation\n - ", "- parent\n continuation\n- "),
|
||||
("- parent\nparagraph\n - ", "- parent\nparagraph\n"),
|
||||
("- parent\nparagraph\n - ", "- parent\nparagraph\n- "),
|
||||
("* * *\n - ", "* * *\n- "),
|
||||
("- - -\n 4) ", "- - -\n 4) "),
|
||||
("- parent\n + ", "- parent\n- "),
|
||||
("- parent\r\n - ", "- parent\r\n- "),
|
||||
("prose", "prose\n"), ("* * *", "* * *\n"),
|
||||
@@ -290,6 +292,31 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
|
||||
#expect(editor.markdownContents.string == after)
|
||||
#expect(editor.selectedRange() == NSRange(location: editor.string.utf16.count, length: 0))
|
||||
}
|
||||
// Empty means whitespace-only body, even before or within the extra separators.
|
||||
for marker in ["-", "12)", "- [X]", "3. []"] {
|
||||
let source = "- parent\n " + marker + " \t "
|
||||
let bodyStart = ("- parent\n " + marker + " ").utf16.count
|
||||
for position in bodyStart...source.utf16.count {
|
||||
editor.setMarkdownContents(NSAttributedString(string: source))
|
||||
editor.setSelectedRange(editor.renderedRange(for: NSRange(location: position, length: 0)))
|
||||
editor.keyDown(with: enter)
|
||||
#expect(editor.markdownString == "- parent\n- ",
|
||||
"Empty Return at \(position) in \(source.debugDescription)")
|
||||
}
|
||||
}
|
||||
for (source, steps) in [
|
||||
(" + \t ", [" + \t ", " + \t ", "+ \t ", ""]),
|
||||
("\t\t12) ", ["\t12) ", "12) ", ""]),
|
||||
(" - [X] ", [" - [X] ", "- [X] ", ""]),
|
||||
] {
|
||||
editor.setMarkdownContents(NSAttributedString(string: source))
|
||||
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
||||
for expected in steps {
|
||||
editor.keyDown(with: enter)
|
||||
#expect(editor.markdownString == expected)
|
||||
#expect(editor.selectedRange() == NSRange(location: editor.string.utf16.count, length: 0))
|
||||
}
|
||||
}
|
||||
reset("- ab🦊cd")
|
||||
editor.setSelectedRange((editor.string as NSString).range(of: "🦊"))
|
||||
editor.keyDown(with: enter)
|
||||
@@ -350,6 +377,121 @@ func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
|
||||
#expect(restored.notes.first { $0.id == id }?.contents.string == "7. saved\n8. ")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func listTabAndShiftTabPreserveMarkersSourceSelectionsUndoAndPersistence() throws {
|
||||
_ = NSApplication.shared
|
||||
let suite = "NotesIndent.\(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
|
||||
window.setContentSize(NSSize(width: 750, height: 390))
|
||||
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)
|
||||
func key(_ shift: Bool) throws -> NSEvent {
|
||||
try #require(NSEvent.keyEvent(with: .keyDown, location: .zero,
|
||||
modifierFlags: shift ? .shift : [], timestamp: 1, windowNumber: window.windowNumber,
|
||||
context: nil, characters: shift ? "\u{19}" : "\t", charactersIgnoringModifiers: "\t",
|
||||
isARepeat: false, keyCode: 48))
|
||||
}
|
||||
let tab = try key(false), backtab = try key(true)
|
||||
func reset(_ source: String) {
|
||||
editor.setMarkdownContents(NSAttributedString(string: source))
|
||||
editor.breakUndoCoalescing()
|
||||
editor.undoManager?.removeAllActions()
|
||||
}
|
||||
for marker in ["-", "*", "+", "009)", "12.", "- [X]", "2) []"] {
|
||||
let source = " " + marker + " \t🦊 café\n - child"
|
||||
reset(source)
|
||||
let body = (editor.string as NSString).range(of: "🦊 café")
|
||||
// Visible indentation, marker, body start/middle/end, and a Unicode selection.
|
||||
let selections = [NSRange(location: 0, length: 0), NSRange(location: 1, length: 0),
|
||||
NSRange(location: 3, length: 0), NSRange(location: body.location, length: 0),
|
||||
NSRange(location: body.location + 2, length: 0),
|
||||
NSRange(location: NSMaxRange(body), length: 0), body]
|
||||
for selection in selections {
|
||||
reset(source)
|
||||
editor.setSelectedRange(selection)
|
||||
let logical = editor.markdownInsertionRange(for: selection)
|
||||
editor.keyDown(with: tab)
|
||||
editor.breakUndoCoalescing()
|
||||
#expect(editor.markdownString == " " + source)
|
||||
#expect(editor.markdownContents.string == " " + source)
|
||||
#expect(editor.selectedRange() == editor.renderedRange(for:
|
||||
NSRange(location: logical.location + 2, length: logical.length)))
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.markdownString == source)
|
||||
#expect(editor.selectedRange() == selection)
|
||||
#expect(editor.undoManager?.canUndo == false)
|
||||
editor.undoManager?.redo()
|
||||
#expect(editor.markdownString == " " + source)
|
||||
editor.keyDown(with: backtab)
|
||||
#expect(editor.markdownString == source)
|
||||
#expect(editor.selectedRange() == editor.renderedRange(for: logical))
|
||||
}
|
||||
}
|
||||
for (indent, remaining) in [("\t ", " "), (" ", " "), (" ", ""), ("", "")] {
|
||||
for marker in ["+", "12)", "- [ ]"] {
|
||||
let source = indent + marker + " 🦊 café"
|
||||
reset(source)
|
||||
let body = (editor.string as NSString).range(of: "🦊 café")
|
||||
editor.setSelectedRange(body)
|
||||
editor.keyDown(with: backtab)
|
||||
#expect(editor.markdownString == remaining + marker + " 🦊 café")
|
||||
#expect((editor.string as NSString).substring(with: editor.selectedRange()) == "🦊 café")
|
||||
if indent.isEmpty { #expect(editor.undoManager?.canUndo == false) }
|
||||
}
|
||||
}
|
||||
for source in ["prose", "* * *", "```md\n- literal", "~~~\n 12) literal"] {
|
||||
reset(source)
|
||||
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
||||
#expect(!controller.textView(editor, doCommandBy: #selector(NSResponder.insertTab(_:))))
|
||||
#expect(!controller.textView(editor, doCommandBy: #selector(NSResponder.insertBacktab(_:))))
|
||||
#expect(editor.markdownString == source)
|
||||
}
|
||||
reset("- root")
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
for _ in 0..<3 { editor.keyDown(with: backtab) }
|
||||
#expect(editor.markdownString == "- root")
|
||||
#expect(editor.undoManager?.canUndo == false)
|
||||
// Tab completes a still-literal typed marker rather than indenting it.
|
||||
for marker in ["-", "12)", "- [X]"] {
|
||||
reset(" ")
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
for character in marker {
|
||||
editor.insertText(String(character), replacementRange: editor.selectedRange())
|
||||
}
|
||||
#expect(editor.string == marker + " ")
|
||||
editor.keyDown(with: tab)
|
||||
#expect(editor.markdownString == marker + "\t ")
|
||||
#expect(editor.string == "\u{fffc}\t ")
|
||||
editor.keyDown(with: tab)
|
||||
#expect(editor.markdownString == " " + marker + "\t ")
|
||||
}
|
||||
reset("- 🦊 café")
|
||||
editor.setSelectedRange((editor.string as NSString).range(of: "café"))
|
||||
editor.keyDown(with: tab)
|
||||
editor.breakUndoCoalescing()
|
||||
#expect(editor.markdownString == " - 🦊 café")
|
||||
#expect((editor.string as NSString).substring(with: editor.selectedRange()) == "café")
|
||||
#expect(controller.save())
|
||||
let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:)))
|
||||
#expect(try NotesStore(directory: directory).notes.first { $0.id == id }?.contents.string == " - 🦊 café")
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.markdownString == "- 🦊 café")
|
||||
#expect(editor.undoManager?.canUndo == false)
|
||||
editor.undoManager?.redo()
|
||||
#expect(editor.markdownString == " - 🦊 café")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func taskCheckboxesToggleMarkdownWithUndoPersistenceAndListContinuation() throws {
|
||||
_ = NSApplication.shared
|
||||
|
||||
Reference in New Issue
Block a user