fix: complete task tokens on trailing whitespace
This commit is contained in:
@@ -141,6 +141,8 @@ 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 task markers rendered as checkbox tokens. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and task 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.
|
Edit Markdown directly: headings, bold, italic, lists, links, inline code, and fenced code blocks are highlighted while the source remains visible, except for task markers rendered as checkbox tokens. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and task 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.
|
||||||
|
|
||||||
|
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 `[ ]`.
|
||||||
|
|
||||||
Task-list markers such as `- [ ]` and `- [x]` display as atomic inline native checkboxes, including nested tasks. Indentation and following whitespace remain editable text; selecting a checkbox selects the whole marker, with no hidden source characters. Click to toggle completion without moving the selection. Backspace immediately after a checkbox 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 a task’s preceding newline after prose makes its marker literal inline text; joining onto an empty or indentation-only line keeps a valid checkbox. Saved notes and copied text retain the original Markdown spelling, case, and spacing, alongside embedded images. Task syntax inside fenced code or ordinary prose stays literal.
|
Task-list markers such as `- [ ]` and `- [x]` display as atomic inline native checkboxes, including nested tasks. Indentation and following whitespace remain editable text; selecting a checkbox selects the whole marker, with no hidden source characters. Click to toggle completion without moving the selection. Backspace immediately after a checkbox 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 a task’s preceding newline after prose makes its marker literal inline text; joining onto an empty or indentation-only line keeps a valid checkbox. Saved notes and copied text retain the original Markdown spelling, case, and spacing, alongside embedded images. Task syntax inside fenced code or ordinary prose stays literal.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static let listItem = try! NSRegularExpression(
|
private static let listItem = try! NSRegularExpression(
|
||||||
pattern: "^([ \\t]*)([-+*]|([0-9]+)([.)]))([ \\t]+|$)(?:\\[([ xX])\\]([ \\t]+|$))?"
|
pattern: "^([ \\t]*)([-+*]|([0-9]+)([.)]))([ \\t]+|$)(?:\\[([ xX]?)\\]([ \\t]+|$))?"
|
||||||
)
|
)
|
||||||
|
|
||||||
private static func indentationWidth(_ indentation: String) -> Int {
|
private static func indentationWidth(_ indentation: String) -> Int {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ private let sourceTaskAttachment = NSAttributedString.Key("Conjure.sourceTaskAtt
|
|||||||
|
|
||||||
private final class TaskAttachment: NSTextAttachment {
|
private final class TaskAttachment: NSTextAttachment {
|
||||||
let marker: String
|
let marker: String
|
||||||
var checked: Bool { !marker.hasSuffix("[ ]") }
|
var checked: Bool { marker.hasSuffix("[x]") || marker.hasSuffix("[X]") }
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
init(marker: String) {
|
init(marker: String) {
|
||||||
@@ -27,7 +27,7 @@ private final class TaskAttachmentCell: NSTextAttachmentCell {
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class NotesTextView: NSTextView {
|
final class NotesTextView: NSTextView {
|
||||||
private static let taskPattern = try! NSRegularExpression(
|
private static let taskPattern = try! NSRegularExpression(
|
||||||
pattern: "(?m)^[ \\t]*((?:[-+*]|[0-9]+[.)])[ \\t]+\\[([ xX])\\])(?=[ \\t]|\\r?$)"
|
pattern: "(?m)^[ \\t]*((?:[-+*]|[0-9]+[.)])[ \\t]+\\[([ xX]?)\\])(?=[ \\t])"
|
||||||
)
|
)
|
||||||
private var tasks: [(range: NSRange, attachment: TaskAttachment)] = []
|
private var tasks: [(range: NSRange, attachment: TaskAttachment)] = []
|
||||||
private var checkboxes: [NSButton] = []
|
private var checkboxes: [NSButton] = []
|
||||||
@@ -321,8 +321,10 @@ final class NotesTextView: NSTextView {
|
|||||||
let selection = selectedRange()
|
let selection = selectedRange()
|
||||||
let marker = task.attachment.marker as NSString
|
let marker = task.attachment.marker as NSString
|
||||||
let range = markdownRange(for: task.range)
|
let range = markdownRange(for: task.range)
|
||||||
|
let opening = marker.range(of: "[", options: .backwards).location
|
||||||
replaceMarkdown(task.attachment.checked ? " " : "x",
|
replaceMarkdown(task.attachment.checked ? " " : "x",
|
||||||
range: NSRange(location: range.location + marker.length - 2, length: 1))
|
range: NSRange(location: range.location + opening + 1,
|
||||||
|
length: marker.length - opening - 2))
|
||||||
setSelectedRange(selection)
|
setSelectedRange(selection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -589,3 +589,70 @@ func atomicTasksCopySelectAndPersistMarkdownAlongsideUnicodeAndImages() throws {
|
|||||||
#expect(reopenedEditor.string == rendered)
|
#expect(reopenedEditor.string == rendered)
|
||||||
#expect(reopenedEditor.markdownContents.string == source.string)
|
#expect(reopenedEditor.markdownContents.string == source.string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func taskTokensRequireTrailingWhitespaceAndAcceptEmptyBracketsWhileTyping() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let suite = "TaskSyntax.\(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)
|
||||||
|
for marker in ["- [ ]", "- []", "- [x]", "- [X]", "\t+ []", "12) []"] {
|
||||||
|
editor.setMarkdownContents(NSAttributedString(string: ""))
|
||||||
|
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||||
|
var typed = ""
|
||||||
|
for character in marker {
|
||||||
|
typed.append(character)
|
||||||
|
editor.insertText(String(character), replacementRange: editor.selectedRange())
|
||||||
|
#expect(editor.string == typed)
|
||||||
|
}
|
||||||
|
editor.insertText(" ", replacementRange: editor.selectedRange())
|
||||||
|
let indentation = String(marker.prefix { $0 == " " || $0 == "\t" })
|
||||||
|
#expect(editor.string == indentation + "\u{fffc} ")
|
||||||
|
let checkbox = try #require(editor.subviews.compactMap { $0 as? NSButton }.first)
|
||||||
|
#expect(checkbox.state == (marker.hasSuffix("[x]") || marker.hasSuffix("[X]") ? .on : .off))
|
||||||
|
editor.insertText("🦊", replacementRange: editor.selectedRange())
|
||||||
|
#expect(editor.string == indentation + "\u{fffc} 🦊")
|
||||||
|
#expect(editor.markdownString == marker + " 🦊")
|
||||||
|
}
|
||||||
|
editor.setMarkdownContents(NSAttributedString(string: "- []\n"))
|
||||||
|
#expect(editor.string == "- []\n")
|
||||||
|
editor.setMarkdownContents(NSAttributedString(string: "- []\ttask"))
|
||||||
|
#expect(editor.string == "\u{fffc}\ttask")
|
||||||
|
editor.breakUndoCoalescing()
|
||||||
|
editor.undoManager?.removeAllActions()
|
||||||
|
let box = try #require(editor.subviews.compactMap { $0 as? NSButton }.first)
|
||||||
|
box.performClick(nil)
|
||||||
|
#expect(editor.markdownString == "- [x]\ttask")
|
||||||
|
#expect(box.state == .on)
|
||||||
|
editor.undoManager?.undo()
|
||||||
|
#expect(editor.markdownString == "- []\ttask")
|
||||||
|
#expect(box.state == .off)
|
||||||
|
editor.undoManager?.redo()
|
||||||
|
#expect(editor.markdownString == "- [x]\ttask")
|
||||||
|
box.performClick(nil)
|
||||||
|
#expect(editor.markdownString == "- [ ]\ttask")
|
||||||
|
let enter = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
|
||||||
|
timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "\r",
|
||||||
|
charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36))
|
||||||
|
editor.insertText("- [] task", replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
|
||||||
|
editor.keyDown(with: enter)
|
||||||
|
#expect(editor.markdownString == "- [] task\n- [ ] ")
|
||||||
|
#expect(editor.string == "\u{fffc} task\n\u{fffc} ")
|
||||||
|
editor.keyDown(with: enter)
|
||||||
|
#expect(editor.markdownString == "- [] task\n")
|
||||||
|
editor.insertText("- [] ", replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
|
||||||
|
editor.keyDown(with: enter)
|
||||||
|
#expect(editor.markdownString.isEmpty)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user